import get from 'lodash-es/get.js'
import isobj from 'wsemi/src/isobj.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import cint from 'wsemi/src/cint.mjs'
import delay from 'wsemi/src/delay.mjs'
import strleft from 'wsemi/src/strleft.mjs'
import strdelleft from 'wsemi/src/strdelleft.mjs'
import strTruncate from 'wsemi/src/strTruncate.mjs'
import getErrorResult from './getErrorResult.mjs'
// dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
//
// 【為何需要】opencode CLI調用的deepseek(OpenCode Zen閘道)與agnes-ai本體都是
// OpenAI相容REST API, 直呼即可免安裝CLI、免預先登入(2026-08-11於本機實測):
// OpenCode Zen — https://opencode.ai/zen/v1 (金鑰同auth.json之sk-..., 模型名去掉opencode/前綴)
// Agnes — https://apihub.agnes-ai.com/v1
// 實測四把金鑰直呼皆200; 壞金鑰回401(Zen: `{"type":"AuthError","message":"Invalid API key."}`,
// Agnes: `{"message":"无效的令牌...","type":"AgnesAI_error"}`)。
//
// 【與CLI轉接器之差異】prompt走HTTP body無命令列長度限制; 錯誤依HTTP狀態碼精確分流
// (401/403金鑰、429限流、5xx服務端), 不再依賴stderr字串猜測; 純completion無agentic
// 能力(不讀檔不跑指令), 天然無寫檔風險。
// 注意: claude與codex走訂閱帳號登入態而非API金鑰, 無法比照, 仍須CLI轉接器。
//
// 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
// 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
//
// 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts },
// stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
// 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
// (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
//預設值
let DEFAULT_TIMEOUT_MS = 120000
let DEFAULT_RETRY_DELAY_MS = 5000
let MAX_RETRY_DELAY_MS = 15000
//optTruncate, 裁切失敗結果之內容時於刪節號後標註原始總長度(同execCli)
let optTruncate = {
funWithMsg: (str) => `(truncated, total ${str.length} chars)`,
}
/**
* 建立驗證函式(規則語法同execCli之validate)
*
* @param {String|Function} rule 輸入驗證規則字串('nonempty'、'json'、'min:100', 逗號可串接)或自訂函式
* @returns {Function|null} 回傳驗證函式,無有效規則回傳null
*/
function buildValidator(rule) {
//自訂函式直接使用
if (isfun(rule)) {
return rule
}
//check
if (!isestr(rule)) {
return null
}
//checks
let checks = rule.split(',').map((r) => r.trim()).filter(Boolean)
if (checks.length === 0) {
return null
}
return (stdout) => {
for (let check of checks) {
if (check === 'nonempty') {
if (!isestr(stdout) || stdout.trim() === '') {
return false
}
}
else if (check === 'json') {
try {
JSON.parse(stdout)
}
catch {
return false
}
}
else if (strleft(check, 4) === 'min:') {
//規則本身無效(如min:abc) → 視為驗證失敗, 不靜默跳過
let smin = strdelleft(check, 4)
if (!isnum(smin)) {
return false
}
let min = cint(smin)
if (!isestr(stdout) || stdout.length < min) {
return false
}
}
}
return true
}
}
/**
* 單次HTTP呼叫(內部使用, 不含重試邏輯)
*
* @param {String} url 輸入完整端點網址字串
* @param {Object} headers 輸入請求標頭物件
* @param {Object} body 輸入請求本體物件
* @param {Number} timeoutMs 輸入逾時毫秒
* @param {Function|null} validator 輸入驗證函式
* @returns {Promise} 回傳Promise,resolve回傳結果物件
*/
async function callOnce(url, headers, body, timeoutMs, validator) {
let t0 = Date.now()
//AbortController, 逾時中止(含回應本體之串流讀取)
let controller = new AbortController()
let timer = setTimeout(() => {
controller.abort()
}, timeoutMs)
let res = null
let txt = ''
try {
res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
})
txt = await res.text()
}
catch (err) {
clearTimeout(timer)
let durationMs = Date.now() - t0
//逾時, error以TIMEOUT開頭令dispatchAiFallback視為與金鑰無關而跳組
if (err.name === 'AbortError') {
return {
ok: false,
stdout: '',
stderr: '',
code: null,
error: `TIMEOUT after ${timeoutMs / 1000}s`,
durationMs,
}
}
//網路層錯誤(DNS/連線拒絕等)
let cause = get(err, 'cause.code', '') || err.message
return {
ok: false,
stdout: '',
stderr: '',
code: null,
error: `FETCH_ERROR: ${cause}`,
durationMs,
}
}
clearTimeout(timer)
let durationMs = Date.now() - t0
//HTTP非2xx, 原始回應本體放stderr供除錯與分類
if (!res.ok) {
return {
ok: false,
stdout: '',
stderr: strTruncate(txt, 1000, optTruncate),
code: res.status,
error: `HTTP ${res.status}`,
durationMs,
}
}
//取出choices[0].message.content
let content = null
try {
let j = JSON.parse(txt)
content = get(j, 'choices.0.message.content', null)
}
catch {}
if (content === null || content === undefined) {
return {
ok: false,
stdout: '',
stderr: strTruncate(txt, 500, optTruncate),
code: res.status,
error: 'INVALID_RESPONSE: missing choices[0].message.content',
durationMs,
}
}
if (typeof content !== 'string') {
content = JSON.stringify(content) //少數閘道回array形態
}
//validator, error與execCli一致令dispatchAiFallback可統一分流
if (validator && !validator(content)) {
return {
ok: false,
stdout: strTruncate(content, 500, optTruncate),
stderr: '',
code: res.status,
error: 'OUTPUT_VALIDATION_FAILED',
durationMs,
}
}
return {
ok: true,
stdout: content,
stderr: '',
code: res.status,
error: '',
durationMs,
}
}
//本轉接器不使用execCli, 全部設定鍵自理, 未知鍵一律忽略
/**
* 以fetch直呼OpenAI相容API(chat/completions)呼叫AI模型
*
* 特點:
* 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen、Agnes等OpenAI相容閘道);
* prompt走HTTP body,無命令列長度限制;
* 錯誤依HTTP狀態碼分流:4xx(429除外)為客戶端錯誤不重試,429/5xx/網路錯誤/逾時依maxRetries線性退避重試;
* 結果結構與逾時/驗證失敗之error字樣對齊execCli,可直接作為dispatchAi與dispatchAiFallback之kind('api-openai-compat')使用;
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
*
* @param {String} prompt 輸入提示詞字串,作為user訊息置於HTTP body
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} opt.baseURL 輸入API基底網址字串,例如'https://opencode.ai/zen/v1'、'https://apihub.agnes-ai.com/v1',將於尾端接上/chat/completions
* @param {String} opt.model 輸入模型ID字串,例如'deepseek-v4-flash-free'(Zen之模型名不帶opencode/前綴)、'agnes-2.0-flash'
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
* @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
* @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),預設120000
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
* @example
* //need network, no cli required
*
* import dispatchApiOpenaiCompat from './src/dispatchApiOpenaiCompat.mjs'
*
* let test = async () => {
*
* //OpenCode Zen(即opencode CLI之自家閘道), 模型名不帶opencode/前綴
* let r1 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
* baseURL: 'https://opencode.ai/zen/v1',
* key: 'sk-xxxxxx',
* model: 'deepseek-v4-flash-free',
* })
* console.log(r1.ok, r1.stdout.trim())
* // => true 完成
*
* //Agnes
* let r2 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
* baseURL: 'https://apihub.agnes-ai.com/v1',
* key: 'sk-xxxxxx',
* model: 'agnes-2.0-flash',
* })
* console.log(r2.ok, r2.stdout.trim())
* // => true 完成
*
* let re = await dispatchApiOpenaiCompat('abc', { baseURL: 'https://opencode.ai/zen/v1', key: 'sk-bad', model: 'deepseek-v4-flash-free' })
* console.log(re.ok, re.code, re.error)
* // => false 401 HTTP 401
*
* }
* await test()
* .catch((err) => {
* console.log(err)
* })
*
*/
async function dispatchApiOpenaiCompat(prompt, opt = {}) {
//check prompt, 不reject故以錯誤結果物件回報
if (!isestr(prompt)) {
return getErrorResult('prompt must be a non-empty string')
}
//baseURL必填, API無CLI可回退
let baseURL = get(opt, 'baseURL', null)
if (!isestr(baseURL)) {
return getErrorResult('baseURL must be a non-empty string')
}
//model必填, chat/completions無預設模型
let model = get(opt, 'model', null)
if (!isestr(model)) {
return getErrorResult('model must be a non-empty string')
}
//key, 無效代表不帶認證標頭(部分閘道免認證)
let key = get(opt, 'key', null)
//system
let system = get(opt, 'system', null)
//bodyExtra
let bodyExtra = get(opt, 'body', null)
if (!isobj(bodyExtra)) {
bodyExtra = {}
}
//headersExtra
let headersExtra = get(opt, 'headers', null)
if (!isobj(headersExtra)) {
headersExtra = {}
}
//timeoutMs
let timeoutMs = get(opt, 'timeoutMs', null)
if (!ispint(timeoutMs)) {
timeoutMs = DEFAULT_TIMEOUT_MS
}
else {
timeoutMs = cint(timeoutMs)
}
//maxRetries
let maxRetries = get(opt, 'maxRetries', null)
if (!isp0int(maxRetries)) {
maxRetries = 0
}
else {
maxRetries = cint(maxRetries)
}
//retryDelayMs
let retryDelayMs = get(opt, 'retryDelayMs', null)
if (!ispint(retryDelayMs)) {
retryDelayMs = DEFAULT_RETRY_DELAY_MS
}
else {
retryDelayMs = cint(retryDelayMs)
}
//validator
let validator = buildValidator(get(opt, 'validate', null))
//url, baseURL尾端斜線正規化後接上端點
let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
//messages
let messages = []
if (isestr(system)) {
messages.push({ role: 'system', content: system })
}
messages.push({ role: 'user', content: prompt })
//body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
let body = { model, messages, ...bodyExtra }
//headers
let headers = { 'Content-Type': 'application/json', ...headersExtra }
if (isestr(key)) {
headers['Authorization'] = `Bearer ${key}`
}
let lastResult = null
let totalAttempts = 0
for (let attempt = 0; attempt <= maxRetries; attempt++) {
//delay, 重試間隔隨次數遞增, 上限15000ms(同execCli)
if (attempt > 0) {
await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
}
lastResult = await callOnce(url, headers, body, timeoutMs, validator)
totalAttempts = attempt + 1
if (lastResult.ok) {
lastResult.attempts = totalAttempts
return lastResult
}
//不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
let c = lastResult.code
if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
break
}
}
lastResult.attempts = totalAttempts
return lastResult
}
export default dispatchApiOpenaiCompat