import get from 'lodash-es/get.js'
import isobj from 'wsemi/src/isobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import cint from 'wsemi/src/cint.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import delay from 'wsemi/src/delay.mjs'
import castPintOr from './castPintOr.mjs'
import buildValidator from './buildValidator.mjs'
import strTruncate from 'wsemi/src/strTruncate.mjs'
import getErrorResult from './getErrorResult.mjs'
import dfTimeoutMs from './dfTimeoutMs.mjs'
import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
import describeNonJsonBody from './describeNonJsonBody.mjs'
// dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
//
// 【為何需要】opencode CLI調用的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轉接器。
//
// 【本轉接器不支援工具, 需要工具請改用CLI類kind(2026-08-11實測後之決策)】
// 閘道端零內建工具: 實測Zen與Agnes皆對`tools:[{type:'web_search'}]`回400並要求
// function.parameters, 即只接受「呼叫端自行定義且自行執行」之function工具;
// 協定層雖支援function calling(Zen之nemotron-3-ultra-free與Agnes皆實測回
// finish_reason:'tool_calls'且tool_calls格式標準), 但工具之定義、執行、錯誤處理與
// 安全邊界全須本套件自行實作與維護, 等同重造CLI已提供之harness, 故不做。
// 又tool_calls有會話束縛(tool_call_id須於同一條messages串內回填, 且須保留前文),
// 該messages串活在本函數單次呼叫之生命週期內, 無法暫停後跨行程外傳給上層agent代跑
// ——工作流是被上層阻塞呼叫的函式, 沒有反向請求工具的通道; 同理工作流(如runFanout)
// 之各名額亦只是同行程之async函數呼叫, 無法把tool_calls往上層轉送。
// 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
// (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
//
// 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
// 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
// JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
// 改帶identity即得正常JSON)。本機以假伺服器重現該機制。重現條件(安裝方tai-kns-trade, 2026-09-24 11:5x實測):
// 長回應(chat/completions約35秒)時Node fetch所見回應標頭為0個、本體9,224 bytes為brotli位元組, 改帶identity
// 則本體為22,124 bytes之JSON; 同環境同時段他端點標頭正常(npm registry 13個、Zen /models 8個含content-encoding=br)
// 且未設代理。本機同日對Zen取樣7次(含65KB長回應)皆正確標示br、未重現——推測漏標為有條件出現(長回應),
// 條件未定。identity請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境),
// 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
//
// 【HTTP 200但本體非JSON另報(安裝方建議C2)】JSON.parse失敗時不再與「缺choices[0].message.content」同一句,
// 改回INVALID_RESPONSE: body is not JSON(附原始位元組數、前16位元組hex、content-encoding、content-type,
// 規則單一來源見describeNonJsonBody.mjs), 遞補層據此整組跳過; 「JSON缺content」維持換金鑰。
//
// 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
// 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
// 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
// (errorType incomplete, 結果帶truncated:true), 不論validate為何——「validate接受」不代表內容完整。
// 呼叫端明示acceptTruncated:true才放行length之截斷(交validate裁決, 通過者仍標truncated:true);
// content_filter與可見輸出為空者一律失敗。finish_reason為stop/null/未知值者照舊(不可誤殺)。
//
// 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止; 截斷亦不重試(同一請求必然再截斷);
// 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
//
// 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
// usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
// CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
// 取得並解析回應後之結果另帶finishReason(正規化終止原因, 缺值為'')與truncated(是否截斷)。
// 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
// incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
// stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
// 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
// dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援/本體非JSON整組跳過, 其餘換金鑰。
//預設值
let DEFAULT_TIMEOUT_MS = dfTimeoutMs //全套件統一預設300000
let DEFAULT_RETRY_DELAY_MS = 5000
let MAX_RETRY_DELAY_MS = 15000
//optTruncate, 裁切失敗結果之內容時於刪節號後標註原始總長度(同execCli)
let optTruncate = {
funWithMsg: (str) => `(truncated, total ${str.length} chars)`,
}
/**
* 單次HTTP呼叫(內部使用, 不含重試邏輯)
*
* @param {String} url 輸入完整端點網址字串
* @param {Object} headers 輸入請求標頭物件
* @param {Object} body 輸入請求本體物件
* @param {Number} timeoutMs 輸入逾時毫秒
* @param {Function|null} validator 輸入驗證函式
* @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
* @returns {Promise} 回傳Promise,resolve回傳結果物件
*/
async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
let t0 = Date.now()
//mkResult, 結果形狀之單一來源(欄位對齊execCli, 追加errorType與usage),
//durationMs於呼叫當下計算; 失敗分支各自給errorType, 成功分支不帶(僅失敗結果有此欄)
let mkResult = (patch) => ({
ok: false,
stdout: '',
stderr: '',
code: null,
error: '',
durationMs: Date.now() - t0,
usage: null,
...patch,
})
//AbortController, 逾時中止(含回應本體之串流讀取)
let controller = new AbortController()
let timer = setTimeout(() => {
controller.abort()
}, timeoutMs)
//本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
let res = null
let raw = new Uint8Array(0)
let txt = ''
try {
res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
})
raw = new Uint8Array(await res.arrayBuffer())
txt = new TextDecoder('utf-8').decode(raw)
}
catch (err) {
clearTimeout(timer)
//逾時, error以TIMEOUT開頭令dispatchAiFallback視為與金鑰無關而跳組
if (err.name === 'AbortError') {
return mkResult({ error: `TIMEOUT after ${timeoutMs / 1000}s`, errorType: 'timeout' })
}
//網路層錯誤(DNS/連線拒絕等)
let cause = get(err, 'cause.code', '') || err.message
return mkResult({ error: `FETCH_ERROR: ${cause}`, errorType: 'fetch' })
}
clearTimeout(timer)
//HTTP非2xx, 原始回應本體放stderr供除錯與分類
if (!res.ok) {
return mkResult({
stderr: strTruncate(txt, 1000, optTruncate),
code: res.status,
error: `HTTP ${res.status}`,
errorType: 'http',
})
}
//取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
let content = null
let finishReasonRaw = null
let toolCalls = null
let usage = null
let parsed = true
try {
let j = JSON.parse(txt)
content = get(j, 'choices.0.message.content', null)
finishReasonRaw = get(j, 'choices.0.finish_reason', null)
toolCalls = get(j, 'choices.0.message.tool_calls', null)
usage = get(j, 'usage', null)
if (!isobj(usage)) {
usage = null
}
}
catch {
parsed = false
}
//本體非JSON, 與「JSON缺content」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
if (!parsed) {
return mkResult({
stderr: strTruncate(txt, 500, optTruncate),
code: res.status,
error: describeNonJsonBody(raw, res.headers),
errorType: 'invalid-response',
finishReason: '',
truncated: false,
})
}
//fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
let finishReason = normalizeFinishReason(finishReasonRaw)
let fin = { finishReason, truncated: TRUNCATION_REASONS.includes(finishReason) }
//tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
//(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
if (finishReason === 'tool_calls' || (toolCalls !== null && toolCalls !== undefined)) {
return mkResult({
stderr: strTruncate(txt, 1000, optTruncate),
code: res.status,
error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
errorType: 'tool-unsupported',
usage,
...fin,
})
}
//content字串化(少數閘道回array形態); null/undefined一律視為無內容
if (content === undefined) {
content = null
}
if (content !== null && typeof content !== 'string') {
content = JSON.stringify(content)
}
//截斷, 於null檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
if (fin.truncated) {
let d = judgeTruncated({
finishReason,
content,
acceptTruncated,
validator,
reasoningTokens: get(usage, 'completion_tokens_details.reasoning_tokens', null),
label: `finish_reason=${finishReason}`,
})
if (!d.accept) {
return mkResult({
stdout: strTruncate(content || '', 500, optTruncate),
stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
code: res.status,
error: d.error,
errorType: 'incomplete',
usage,
...fin,
})
}
return mkResult({ ok: true, stdout: content, code: res.status, usage, ...fin })
}
if (content === null) {
return mkResult({
stderr: strTruncate(txt, 500, optTruncate),
code: res.status,
error: 'INVALID_RESPONSE: missing choices[0].message.content',
errorType: 'invalid-response',
usage,
...fin,
})
}
//validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
if (validator) {
let v = safeValidate(validator, content)
if (!v.pass) {
return mkResult({
stdout: strTruncate(content, 500, optTruncate),
stderr: v.threw ? `validate threw: ${v.threw}` : '',
code: res.status,
error: 'OUTPUT_VALIDATION_FAILED',
errorType: 'validation',
usage,
...fin,
})
}
}
return mkResult({
ok: true,
stdout: content,
code: res.status,
usage,
...fin,
})
}
//本轉接器不使用execCli, 全部設定鍵自理, 未知鍵一律忽略
/**
* 以fetch直呼OpenAI相容API(chat/completions)呼叫AI模型
*
* 特點:
* 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen、Agnes等OpenAI相容閘道);
* 僅供純文字生成(摘要、分析、改寫、產出JSON等素材已在prompt內之任務)——
* 需要讀本機檔案、grep、執行指令、抓網頁等工具能力時,請改用CLI類kind(opencode/claude/codex/antigravity);
* 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字串,例如'agnes-3.0-flash'、'kimi-k2.7-code'(Zen之模型名不帶opencode/前綴)
* @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(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),要改回允許壓縮可給{'Accept-Encoding':'gzip, deflate, br'},預設{}
* @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
* @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(finish_reason為length)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗(errorType incomplete)
* @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(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null)、finishReason(已取得回應時之正規化終止原因字串,缺值為'')、truncated(已取得回應時是否被截斷布林值),本函數不會reject
* @example
* //need network, no cli required
*
* import dispatchApiOpenaiCompat from './src/dispatchApiOpenaiCompat.mjs'
*
* let test = async () => {
*
* //Agnes
* let r1 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
* baseURL: 'https://apihub.agnes-ai.com/v1',
* key: 'sk-xxxxxx',
* model: 'agnes-3.0-flash',
* })
* console.log(r1.ok, r1.stdout.trim())
* // => true 完成
*
* //OpenCode Zen(即opencode CLI之自家閘道), 模型名不帶opencode/前綴; 走此端點者見providers.mjs檔頭之端點表
* //注意Zen之免費模型自2026-09-17起多數禁止REST直呼(403 FreeTierError), 該類模型須改走opencode CLI(kind:'opencode');
* //閘門係逐模型套用(如space-bunny-free於2026-09-24實測REST仍200), 能否走REST以實測為準, 例外見providers.mjs檔頭
* let r2 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
* baseURL: 'https://opencode.ai/zen/v1',
* key: 'sk-xxxxxx',
* model: 'kimi-k2.7-code', //付費模型不受免費層限制
* })
* console.log(r2.ok, r2.stdout.trim())
* // => true 完成
*
* let re = await dispatchApiOpenaiCompat('abc', { baseURL: 'https://apihub.agnes-ai.com/v1', key: 'sk-bad', model: 'agnes-3.0-flash' })
* 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 = castPintOr(get(opt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
//maxRetries
let maxRetries = get(opt, 'maxRetries', null)
if (!isp0int(maxRetries)) {
maxRetries = 0
}
else {
maxRetries = cint(maxRetries)
}
//retryDelayMs
let retryDelayMs = castPintOr(get(opt, 'retryDelayMs', null), DEFAULT_RETRY_DELAY_MS)
//validator
let validator = buildValidator(get(opt, 'validate', null))
//acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
let acceptTruncated = get(opt, 'acceptTruncated', null) === true
//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, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...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, acceptTruncated)
totalAttempts = attempt + 1
if (lastResult.ok) {
lastResult.attempts = totalAttempts
return lastResult
}
//不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
if (lastResult.truncated === true) {
break
}
//不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
let c = lastResult.code
if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
break
}
}
lastResult.attempts = totalAttempts
return lastResult
}
export default dispatchApiOpenaiCompat