dispatchApiOpenaiCompat.mjs

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