feat: refactor AI engine logic, remove cross-engine fallback, add retry with backoff, and bump version to 2.80.18

This commit is contained in:
g1nation
2026-05-08 01:24:12 +09:00
parent 6894152892
commit d451a082dd
10 changed files with 104 additions and 75 deletions
+47 -27
View File
@@ -1942,35 +1942,37 @@ export class AgentExecutor {
temperature: number;
}): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> {
const { baseUrl, modelName, reqMessages, temperature } = params;
const primaryEngine = resolveEngine(baseUrl);
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
const engine = resolveEngine(baseUrl); // 사용자가 설정한 엔진만 사용
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const messageVariants = this.buildEngineMessageVariants(reqMessages, engine);
const modelCandidates = this.buildModelCandidates(modelName, engine);
let lastError: Error | null = null;
for (const engine of engines) {
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const messageVariants = this.buildEngineMessageVariants(reqMessages, engine);
const modelCandidates = this.buildModelCandidates(modelName, engine);
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
const streamBody = {
model: candidateModel,
messages: variant.messages,
stream: true,
...(engine === 'lmstudio'
? { max_tokens: 4096, temperature }
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
};
// 같은 엔진 내에서만 model candidate / message variant retry
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
const streamBody = {
model: candidateModel,
messages: variant.messages,
stream: true,
...(engine === 'lmstudio'
? { max_tokens: 4096, temperature }
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
};
// 일시적 네트워크 오류용 retry (최대 2회, 지수 backoff)
const MAX_RETRIES = 2;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
const delay = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms
await new Promise(r => setTimeout(r, delay));
logInfo('AI streaming request retry.', { engine, attempt, model: candidateModel });
}
logInfo('AI streaming request started.', {
engine,
apiUrl,
model: candidateModel,
variant: variant.name,
messageCount: variant.messages.length,
roles: variant.messages.map(message => message.role),
firstUserPreview: summarizeText(String(variant.messages.find(message => message.role === 'user')?.content || ''), 300)
engine, apiUrl, model: candidateModel,
variant: variant.name, messageCount: variant.messages.length,
attempt
});
const response = await fetch(apiUrl, {
method: 'POST',
@@ -1988,7 +1990,12 @@ export class AgentExecutor {
if (!response.ok) {
const errText = await response.text();
lastError = new Error(`AI Engine error (${engine}/${variant.name}): ${response.status} - ${summarizeText(errText, 300)}`);
logError('AI streaming request returned non-OK status.', { engine, variant: variant.name, apiUrl, status: response.status, body: summarizeText(errText, 500) });
logError('AI streaming request returned non-OK status.', {
engine, variant: variant.name, apiUrl,
status: response.status, body: summarizeText(errText, 500)
});
// 4xx는 재시도해도 의미없음. 5xx만 재시도.
if (response.status >= 400 && response.status < 500) break;
continue;
}
@@ -1996,13 +2003,26 @@ export class AgentExecutor {
return { response, engine, apiUrl };
} catch (error: any) {
lastError = error instanceof Error ? error : new Error(String(error));
logError('AI streaming request failed.', { engine, variant: variant.name, apiUrl, model: candidateModel, error: lastError.message });
// AbortError는 사용자가 취소한 것이므로 retry 금지
if (lastError.name === 'AbortError') {
throw lastError;
}
logError('AI streaming request failed.', {
engine, variant: variant.name, apiUrl, model: candidateModel,
attempt, error: lastError.message
});
}
}
}
}
throw lastError || new Error('Unable to connect to AI engine.');
// 명확한 에러 메시지: 어느 엔진이 실패했는지 사용자에게 알림
const engineLabel = engine === 'lmstudio' ? 'LM Studio' : 'Ollama';
throw new Error(
`${engineLabel} 엔진에 연결할 수 없습니다. ` +
`${engineLabel}가 실행 중이고 모델 '${modelName}'이 로드되어 있는지 확인하세요. ` +
`(원인: ${lastError?.message || 'unknown'})`
);
}
private normalizeMessages(messages: ChatMessage[]) {
+8
View File
@@ -90,6 +90,14 @@ export function deactivate() {
}
async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
if (existingUrl && existingUrl.trim()) {
context.globalState.update('setupComplete', true);
logInfo('Initial setup skipped: ollamaUrl already configured.', { existingUrl });
return;
}
try {
let engineName = '';
let modelName = '';
+30 -25
View File
@@ -51,6 +51,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
private _currentSessionBrainId: string | null = null;
private _currentNegativePrompt: string = '';
private readonly _chronicle = new ProjectChronicleManager();
private _modelDiscoveryInFlight = false;
constructor(
private readonly _extensionUri: vscode.Uri,
@@ -75,13 +76,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
};
// [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
let _lastVisibilityRefresh = 0;
webviewView.onDidChangeVisibility(() => {
if (webviewView.visible) {
logInfo('Sidebar became visible, restoring state...');
void this._sendModels();
void this._sendBrainProfiles();
void this._sendAgentsList();
}
if (!webviewView.visible) return;
const now = Date.now();
// 5초 이내에 이미 갱신했으면 건너뜀
if (now - _lastVisibilityRefresh < 5000) return;
_lastVisibilityRefresh = now;
logInfo('Sidebar became visible, restoring state...');
void this._sendModels();
void this._sendBrainProfiles();
void this._sendAgentsList();
});
webviewView.webview.html = this._getHtml(webviewView.webview);
@@ -1950,26 +1956,26 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
private async _sendModels() {
if (!this._view) return;
if (this._modelDiscoveryInFlight) {
logInfo('Model discovery already in progress, skipping.');
return;
}
this._modelDiscoveryInFlight = true;
try {
const config = getConfig();
const url = config.ollamaUrl;
let defaultModel = config.defaultModel;
let models: string[] = [];
const primaryEngine = resolveEngine(url);
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
for (const engine of engines) {
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
logInfo('Model discovery started.', { engine, modelsUrl });
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
const rawText = await res.text();
if (!res.ok) {
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
continue;
}
const engine = resolveEngine(url); // 단일 엔진만
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
logInfo('Model discovery started.', { engine, modelsUrl });
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
const rawText = await res.text();
if (!res.ok) {
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
} else {
const data = rawText ? JSON.parse(rawText) as any : {};
models = engine === 'lmstudio'
? (data.data || []).map((m: any) => m.id)
@@ -1977,11 +1983,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (models.length > 0) {
logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
break;
}
} catch (e: any) {
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
}
} catch (e: any) {
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
}
if (models.length === 0) {
@@ -2017,8 +2022,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel } });
} catch (err) {
logError('Model list update failed.', err);
const fallbackModel = getConfig().defaultModel;
this._view.webview.postMessage({ type: 'modelsList', value: { models: fallbackModel ? [fallbackModel] : [], selected: fallbackModel } });
} finally {
this._modelDiscoveryInFlight = false;
}
}