fix(llm): OpenCode 呼叫加逾時並讓失敗優雅降級(修正 Step5 卡住) #12
+13
-7
@@ -3,6 +3,10 @@ import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
|||||||
import { recordUsage } from './usage.js';
|
import { recordUsage } from './usage.js';
|
||||||
import { line, error } from './log.js';
|
import { line, error } from './log.js';
|
||||||
|
|
||||||
|
// 單次 OpenCode HTTP 請求的逾時(毫秒)。預設 120 秒,可用 OPENCODE_TIMEOUT_MS 覆寫。
|
||||||
|
// 沒有逾時時,server / 模型卡住會讓請求無限等待、整個流程停擺。
|
||||||
|
const OPENCODE_TIMEOUT_MS = Number(process.env.OPENCODE_TIMEOUT_MS) || 120000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。
|
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。
|
||||||
*
|
*
|
||||||
@@ -23,12 +27,14 @@ function opencodeModelConfig(model) {
|
|||||||
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。
|
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。
|
||||||
*
|
*
|
||||||
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。
|
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。
|
||||||
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent }} axios 請求選項物件。
|
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent, timeout: number }} axios 請求選項物件。
|
||||||
|
* @remarks 帶 `timeout`(`OPENCODE_TIMEOUT_MS`,預設 120s),避免 server/模型停滯時請求無限等待。
|
||||||
*/
|
*/
|
||||||
function opencodeAxiosOptions(headers) {
|
function opencodeAxiosOptions(headers) {
|
||||||
return {
|
return {
|
||||||
headers,
|
headers,
|
||||||
httpsAgent: getOpenCodeHttpsAgent(),
|
httpsAgent: getOpenCodeHttpsAgent(),
|
||||||
|
timeout: OPENCODE_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +97,14 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
|
|||||||
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
|
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
|
||||||
*
|
*
|
||||||
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
|
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
|
||||||
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)`
|
* usage 並回傳內容。OpenCode 呼叫失敗(含逾時)時記錄錯誤並向外**拋出例外**,
|
||||||
* 終止整個行程(不會回傳)。
|
* 交由呼叫端處理——多數呼叫端(去重、誤報過濾、補行號)有 try/catch fallback,
|
||||||
|
* 角色分析則在 Step5 以 `Promise.allSettled` 略過失敗的單一角色,達到優雅降級。
|
||||||
*
|
*
|
||||||
* @param {string} systemPrompt - 系統提示詞。
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
* @param {string} userContent - 使用者輸入內容。
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
* @returns {Promise<string>} 模型回應的純文字內容。
|
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||||
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時。
|
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時,或 OpenCode 呼叫失敗/逾時時。
|
||||||
*/
|
*/
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
@@ -112,10 +119,9 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
recordUsage(data);
|
recordUsage(data);
|
||||||
return content;
|
return content;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] OpenCode 呼叫失敗: ${e.message}`);
|
error(`[LLM] OpenCode 呼叫失敗: ${e.message}`);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
error('[LLM] OpenCode 呼叫失敗,終止流程');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+23
-3
@@ -85,14 +85,34 @@ describe('chat - OpenCode', async () => {
|
|||||||
assert.equal(result, 'hello world');
|
assert.equal(result, 'hello world');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls process.exit(1) when OpenCode fails', async () => {
|
it('sets a request timeout on OpenCode calls so a stalled server fails fast', async () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
const timeouts = [];
|
||||||
|
mock.method(axios, 'post', async (url, _payload, opts) => {
|
||||||
|
timeouts.push(opts.timeout);
|
||||||
|
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
||||||
|
return { data: { parts: [{ type: 'text', text: 'ok' }] } };
|
||||||
|
});
|
||||||
|
|
||||||
|
await chat('sys', 'user');
|
||||||
|
|
||||||
|
assert.equal(timeouts.length, 2);
|
||||||
|
for (const t of timeouts) {
|
||||||
|
assert.equal(typeof t, 'number');
|
||||||
|
assert.ok(t > 0, 'OpenCode 請求必須帶正數 timeout,避免無限等待');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws (does not process.exit) when OpenCode fails, so callers can degrade gracefully', async () => {
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
mock.method(axios, 'post', async () => { throw new Error('fail'); });
|
mock.method(axios, 'post', async () => { throw new Error('fail'); });
|
||||||
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); });
|
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); });
|
||||||
|
|
||||||
await assert.rejects(() => chat('sys', 'user'), /exit:1/);
|
// 失敗時向外拋出原始錯誤,而非 process.exit(1),讓 Step5 的 Promise.allSettled
|
||||||
|
// 與去重/過濾的 try/catch fallback 能各自處理,不會整個流程被砍掉。
|
||||||
|
await assert.rejects(() => chat('sys', 'user'), /fail/);
|
||||||
|
|
||||||
assert.equal(exitMock.mock.calls[0].arguments[0], 1);
|
assert.equal(exitMock.mock.calls.length, 0, 'chat 不應再呼叫 process.exit');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user