dispatchApiOpenaiResponses.mjs

import get from 'lodash-es/get.js'
import isarr from 'wsemi/src/isarr.mjs'
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 { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
import describeNonJsonBody from './describeNonJsonBody.mjs'


// dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
//
// 【為何需要(2026-09-03實測後新增)】OpenCode Zen之端點「依模型家族而異」, 並非全部走
//   chat/completions: 官方文件(https://opencode.ai/docs/zh-tw/zen/)之端點欄明載
//   muse-spark系與GPT系走/responses(@ai-sdk/openai)、Claude系走/messages、Gemini系走
//   /models/<id>, 僅deepseek/glm/kimi/minimax/nemotron/ling/mimo等走/chat/completions。
//   以dispatchApiOpenaiCompat打/responses型模型會得到HTTP 500而非404, 極易被誤判為
//   「模型故障」而反覆重試——實測muse-spark-1.2/1.3走/chat/completions連續10次500,
//   同金鑰同模型改打/responses立即200。本轉接器即為補上該端點型別。
//
// 【與chat/completions之協定差異(皆2026-09-03於Zen實測確認)】
//   請求: 單一輸入欄位input(字串或訊息陣列)而非messages; system提示走instructions;
//        輸出上限為max_output_tokens而非max_tokens。
//   回應: 無choices, 改為output陣列, 元素type可為'reasoning'(思考, 實測content為空)、
//        'message'(內容在content[].text, type為'output_text')、'function_call'(工具呼叫);
//        另有頂層status(completed/incomplete/failed)與incomplete_details。
//   用量: usage欄位名不同——input_tokens/output_tokens/total_tokens
//        (chat/completions為prompt_tokens/completion_tokens/total_tokens)。
//        本套件usage一律原樣透傳不做正規化, 跨kind加總時呼叫端須自行對應欄位名。
//
// 【status不為completed預設失敗, 不回半截內容】incomplete(如max_output_tokens
//   耗盡)之output常為空陣列或截斷內容, 當成功回傳會讓截斷結果流入下游而無人察覺;
//   故以INCOMPLETE_RESPONSE回報(errorType為incomplete), 呼叫端據此調高max_output_tokens
//   或換家。實測: max_output_tokens為16時status為incomplete、incomplete_details為
//   {reason:'max_output_tokens'}、output為空陣列。
//   2026-09-24起與dispatchApiOpenaiCompat同一截斷規則(單一來源checkTruncation.mjs): 僅status為incomplete
//   視為截斷(結果帶truncated:true, finishReason將max_output_tokens正規化為length), 呼叫端明示
//   acceptTruncated:true才放行length之截斷(交validate裁決); failed/缺status/處理中等屬非截斷之失敗,
//   truncated:false, 遞補層照舊換金鑰(服務回錯)。可見輸出為空之截斷訊息附reasoning_tokens。
//
// 【不支援工具, 與dispatchApiOpenaiCompat同一決策】output含function_call型元素時
//   以TOOL_CALLS_UNSUPPORTED回報而不假裝成功; 理由(工具迴圈須自建harness、tool_call
//   有會話束縛無法外傳上層agent)詳見dispatchApiOpenaiCompat.mjs與adapters.mjs檔頭。
//
// 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
//   JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
//   HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺output陣列」分開
//   (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
//
// 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
//   supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
//
// 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, errorType, durationMs,
//   attempts, usage }, 與dispatchApiOpenaiCompat完全一致, 故dispatchAiFallback之
//   失敗分流與工作流層無須任何修改即可使用本kind。


//預設值
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)`,
}


/**
 * 自Responses API之output陣列取出文字內容(僅取message型元素之output_text)
 *
 * reasoning型元素為模型思考過程(Zen實測其content為空陣列),不屬回覆內容故略過;
 * 多個message元素依序串接
 *
 * @param {Array} output 輸入回應之output陣列
 * @returns {String} 回傳串接後之文字內容,無有效內容回傳空字串
 * @example
 *
 * import { extractOutputText } from './src/dispatchApiOpenaiResponses.mjs'
 *
 * let output = [
 *     { type: 'reasoning', content: [] },
 *     { type: 'message', content: [{ type: 'output_text', text: '完成' }] },
 * ]
 * console.log(extractOutputText(output))
 * // => '完成'
 *
 */
function extractOutputText(output) {
    if (!isarr(output)) {
        return ''
    }
    let rs = []
    for (let item of output) {
        if (get(item, 'type', '') !== 'message') {
            continue
        }
        let content = get(item, 'content', null)
        if (!isarr(content)) {
            continue
        }
        for (let c of content) {
            let t = get(c, 'text', null)
            if (isestr(t)) {
                rs.push(t)
            }
        }
    }
    return rs.join('')
}


/**
 * 單次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供除錯與分類
    //(Zen實測: 壞金鑰與未知model皆401, 故訊息須讀stderr而非僅看狀態碼)
    if (!res.ok) {
        return mkResult({
            stderr: strTruncate(txt, 1000, optTruncate),
            code: res.status,
            error: `HTTP ${res.status}`,
            errorType: 'http',
        })
    }

    //解析output/status/usage(token用量原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
    let output = null
    let status = ''
    let incompleteReason = ''
    let failMsg = ''
    let usage = null
    let parsed = true
    try {
        let j = JSON.parse(txt)
        output = get(j, 'output', null)
        status = get(j, 'status', '')
        incompleteReason = get(j, 'incomplete_details.reason', '')
        failMsg = get(j, 'error.message', '') || get(j, 'error.code', '')
        usage = get(j, 'usage', null)
        if (!isobj(usage)) {
            usage = null
        }
    }
    catch {
        parsed = false
    }
    //本體非JSON, 與「JSON缺output」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
    if (!parsed) {
        return mkResult({
            stderr: strTruncate(txt, 500, optTruncate),
            code: res.status,
            error: describeNonJsonBody(raw, res.headers),
            errorType: 'invalid-response',
            usage,
            finishReason: '',
            truncated: false,
        })
    }

    //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(與chat/completions對齊, 規則見checkTruncation.mjs):
    //completed→stop; incomplete→依reason(max_output_tokens→length、content_filter原值、缺→incomplete); 其餘status原值; 缺status→''
    let statusN = normalizeFinishReason(status)
    let reasonN = normalizeFinishReason(incompleteReason)
    let finishReason = statusN
    if (statusN === 'completed') {
        finishReason = 'stop'
    }
    else if (statusN === 'incomplete') {
        finishReason = (reasonN === 'max_output_tokens') ? 'length' : (reasonN || 'incomplete')
    }
    let fin = { finishReason, truncated: statusN === 'incomplete' }
    let detail = incompleteReason || failMsg || status || 'unknown'
    let isArr = isarr(output)

    //function_call, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
    let hasToolCall = isArr && output.some((o) => get(o, 'type', '') === 'function_call')
    if (hasToolCall) {
        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,
        })
    }

    //截斷(status為incomplete), 於output檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
    if (fin.truncated) {
        let partial = isArr ? extractOutputText(output) : ''
        let d = judgeTruncated({
            finishReason,
            content: partial,
            acceptTruncated,
            validator,
            reasoningTokens: get(usage, 'output_tokens_details.reasoning_tokens', null),
            label: `status=${status} (${detail})`,
        })
        if (!d.accept) {
            return mkResult({
                stdout: strTruncate(partial, 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: partial, code: res.status, usage, ...fin })
    }

    if (!isArr) {
        return mkResult({
            stderr: strTruncate(txt, 500, optTruncate),
            code: res.status,
            error: 'INVALID_RESPONSE: missing output array',
            errorType: 'invalid-response',
            usage,
            ...fin,
        })
    }

    //其餘非completed(failed/缺status/處理中等)屬非截斷之失敗, 不回半截內容; truncated:false故遞補層照舊換金鑰
    if (statusN !== 'completed') {
        return mkResult({
            stdout: strTruncate(extractOutputText(output), 500, optTruncate),
            stderr: strTruncate(txt, 500, optTruncate),
            code: res.status,
            error: `INCOMPLETE_RESPONSE: status=${status || 'missing'} (${detail})`,
            errorType: 'incomplete',
            usage,
            ...fin,
        })
    }

    //content, 自output取message型之output_text
    let content = extractOutputText(output)
    if (content === '') {
        return mkResult({
            stderr: strTruncate(txt, 500, optTruncate),
            code: res.status,
            error: 'INVALID_RESPONSE: no output_text in output messages',
            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 Responses API(/responses)呼叫AI模型
 *
 * 特點:
 * 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen之muse-spark系與GPT系);
 * 端點型別與dispatchApiOpenaiCompat不同——Zen之端點依模型家族而異,收錄前須查官方文件端點欄
 * (https://opencode.ai/docs/zh-tw/zen/),打錯端點會得到HTTP 500而非404,詳見providers.mjs檔頭;
 * 僅供純文字生成,需要工具能力請改用CLI類kind(opencode/claude/codex/antigravity);
 * status非completed(如max_output_tokens耗盡)一律以INCOMPLETE_RESPONSE回報,不回半截內容;
 * 結果結構與dispatchApiOpenaiCompat完全一致,可直接作為dispatchAi與dispatchAiFallback之kind('api-openai-responses')使用;
 * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
 *
 * @param {String} prompt 輸入提示詞字串,作為input置於HTTP body
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} opt.baseURL 輸入API基底網址字串,例如'https://opencode.ai/zen/v1',將於尾端接上/responses
 * @param {String} opt.model 輸入模型ID字串,例如'muse-spark-1.3-contributor-free'
 * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
 * @param {String} [opt.system=''] 輸入system提示詞字串,將置於instructions欄位(Responses API之system管道),預設''代表不帶
 * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_output_tokens、reasoning),將併入預設body(同名鍵以此為準),預設{}。注意輸出上限欄位名為max_output_tokens而非max_tokens;本轉接器不支援工具,帶入tools而模型回function_call時一律以TOOL_CALLS_UNSUPPORTED回報失敗
 * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
 * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
 * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
 * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(status為incomplete且reason為max_output_tokens)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗
 * @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用量物件原樣透傳,欄位名為input_tokens/output_tokens/total_tokens,無則null)、finishReason(已取得回應時之正規化終止原因字串,completed為'stop'、max_output_tokens為'length')、truncated(已取得回應時是否被截斷布林值,僅status為incomplete時為true),本函數不會reject
 * @example
 * //need network, no cli required
 *
 * import dispatchApiOpenaiResponses from './src/dispatchApiOpenaiResponses.mjs'
 *
 * let test = async () => {
 *
 *     //OpenCode Zen之muse-spark系走/responses(非chat/completions), 詳見providers.mjs檔頭
 *     let r = await dispatchApiOpenaiResponses('請只回覆兩個字:完成', {
 *         baseURL: 'https://opencode.ai/zen/v1',
 *         key: 'sk-xxxxxx',
 *         model: 'muse-spark-1.3-contributor-free',
 *     })
 *     console.log(r.ok, r.stdout.trim())
 *     // => true 完成
 *
 *     let re = await dispatchApiOpenaiResponses('abc', { baseURL: 'https://opencode.ai/zen/v1', key: 'sk-bad', model: 'muse-spark-1.3-contributor-free' })
 *     console.log(re.ok, re.code, re.errorType)
 *     // => false 401 http
 *
 * }
 * await test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function dispatchApiOpenaiResponses(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必填, responses無預設模型
    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, Responses API之system管道為instructions
    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(/\/+$/, '') + '/responses'

    //body, input為Responses API之輸入欄位; 額外鍵以bodyExtra為準(可覆寫temperature等)
    let body = { model, input: prompt }
    if (isestr(system)) {
        body.instructions = system
    }
    body = { ...body, ...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 dispatchApiOpenaiResponses
export { extractOutputText }