import get from 'lodash-es/get.js'
import isnum from 'wsemi/src/isnum.mjs'
import isestr from 'wsemi/src/isestr.mjs'
// checkTruncation.mjs — REST文字轉接器之截斷裁定與安全驗證(單一來源, compat與responses共用)
//
// 【為何需要(2026-09-24實測後之終審方案)】Zen之space-bunny-free帶max_tokens:600時推理階段即耗盡,
// 回finish_reason:"length"、content:""(空字串), 舊版dispatchApiOpenaiCompat不看finish_reason而回ok:true、
// stdout為空——截斷成了無人察覺的成功。同期dispatchApiOpenaiResponses則對status≠completed一律失敗,
// 兩個兄弟轉接器規則不一; 截斷規則若各自手寫, 日後必再分岔, 故收斂於此。
//
// 【規則(兩位Opus 5.5獨立複審後之終審結論, 見README之截斷處理段)】
// ① 截斷一律於validate之前判定, 預設失敗(errorType incomplete)——「validate接受」不代表內容完整:
// nonempty/min:N/rawText不檢查完整性, 寬鬆JSON解析還會從前言取出錯誤片段, 不可推定為同意;
// ② 呼叫端明示acceptTruncated:true才放行, 且僅限length(content_filter為政策性過濾, 殘文一律不收);
// 放行時有validate則交validate裁決, 無validate則直接接受(供轉發型呼叫端), 結果一律標truncated:true;
// ③ 可見輸出為空(null/空字串/純空白)時無物可收, 一律失敗, 訊息標明推理可能耗盡輸出上限並附reasoning_tokens。
//
// 【validate拋錯視同拒絕】呼叫端之validate(或工作流之parse/check)拋錯時, 舊版一路reject至最外層,
// 違反各函數「不會reject」之承諾; safeValidate統一接住並回傳拋錯訊息供轉接器置入stderr診斷。
//截斷終止原因集合(正規化後); 其餘值(stop、null、各家未知值)一律視為非截斷, 不可誤殺
let TRUNCATION_REASONS = ['length', 'content_filter']
/**
* 正規化終止原因字串(去頭尾空白、轉小寫;非字串回傳空字串)
*
* @param {*} v 輸入原始終止原因
* @returns {String} 回傳正規化字串
* @example
*
* import { normalizeFinishReason } from './src/checkTruncation.mjs'
*
* console.log(normalizeFinishReason(' LENGTH '), normalizeFinishReason(null))
* // => 'length' ''
*
*/
function normalizeFinishReason(v) {
return isestr(v) ? v.trim().toLowerCase() : ''
}
/**
* 以不拋錯方式執行驗證函式(拋錯視同拒絕)
*
* @param {Function} validator 輸入驗證函式(text)=>Boolean
* @param {String} text 輸入待驗證文字
* @returns {Object} 回傳物件,內含pass(是否通過布林值)與threw(拋錯訊息字串,未拋錯為空字串)
* @example
*
* import { safeValidate } from './src/checkTruncation.mjs'
*
* console.log(safeValidate((s) => s.length > 1, 'ab'))
* // => { pass: true, threw: '' }
*
* console.log(safeValidate(() => { throw new Error('x') }, 'ab'))
* // => { pass: false, threw: 'x' }
*
*/
function safeValidate(validator, text) {
try {
return { pass: !!validator(text), threw: '' }
}
catch (err) {
let msg = get(err, 'message', '')
return { pass: false, threw: isestr(msg) ? msg : String(err) }
}
}
/**
* 裁定一次已截斷之回應應失敗或放行(呼叫端先判定truncated為true才呼叫)
*
* @param {Object} o 輸入設定物件
* @param {String} o.finishReason 輸入正規化後之終止原因字串,'length'才可能放行
* @param {String|null} o.content 輸入可見輸出文字,null代表無
* @param {Boolean} [o.acceptTruncated=false] 輸入呼叫端是否明示接受截斷內容布林值
* @param {Function|null} [o.validator=null] 輸入驗證函式,放行時交其裁決
* @param {Number|null} [o.reasoningTokens=null] 輸入推理token數,可見輸出為空時附於訊息
* @param {String} o.label 輸入錯誤訊息主體字串,例如'finish_reason=length'或'status=incomplete (max_output_tokens)'
* @returns {Object} 回傳物件,內含accept(是否放行布林值)、error(不放行時之錯誤訊息字串)、threw(validate拋錯訊息字串)
* @example
*
* import { judgeTruncated } from './src/checkTruncation.mjs'
*
* console.log(judgeTruncated({ finishReason: 'length', content: '', reasoningTokens: 600, label: 'finish_reason=length' }).error)
* // => 'INCOMPLETE_RESPONSE: finish_reason=length; no visible output (reasoning may have used up the output token limit, reasoning_tokens=600)'
*
* console.log(judgeTruncated({ finishReason: 'length', content: '[{"a":1},', acceptTruncated: true, label: 'finish_reason=length' }).accept)
* // => true
*
*/
function judgeTruncated(o) {
let finishReason = get(o, 'finishReason', '')
let content = get(o, 'content', null)
let acceptTruncated = get(o, 'acceptTruncated', false) === true
let validator = get(o, 'validator', null)
let reasoningTokens = get(o, 'reasoningTokens', null)
let base = `INCOMPLETE_RESPONSE: ${get(o, 'label', '')}`
//可見輸出為空: 無物可收, 一律失敗並標明可能為推理耗盡
let visible = isestr(content) ? content.trim() : ''
if (visible === '') {
let rt = isnum(reasoningTokens) ? `, reasoning_tokens=${reasoningTokens}` : ''
return { accept: false, error: `${base}; no visible output (reasoning may have used up the output token limit${rt})`, threw: '' }
}
//content_filter等非length之截斷一律不收; 未明示同意亦不收
if (finishReason !== 'length' || !acceptTruncated) {
return { accept: false, error: base, threw: '' }
}
//明示同意: 有validate交其裁決, 無validate直接接受
if (typeof validator === 'function') {
let v = safeValidate(validator, content)
if (!v.pass) {
return { accept: false, error: `${base}; rejected by validate`, threw: v.threw }
}
}
return { accept: true, error: '', threw: '' }
}
export { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated }