import path from 'path'
import get from 'lodash-es/get.js'
import isobj from 'wsemi/src/isobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import fsReadJson from 'wsemi/src/fsReadJson.mjs'
import fsWriteJson from 'wsemi/src/fsWriteJson.mjs'
// createUsageCounter.mjs — 逐日、逐鍵之AI用量計帳(純觀測)
//
// 【純觀測, 絕不據以節流或阻擋——本檔最重要的警語】用量門檻是臆測值: 服務端額度視窗
// 形態多樣(逐日/逐時/5小時滾動/以token計而非以次數計), 呼叫端量得到的只有次數。
// 以臆測門檻擋自己的呼叫等同拿猜測當事實: 太保守則白白不用已有額度, 太寬鬆則毫無作用。
// 正確作法是「打下去、失敗了換下一家」(dispatchAiFallback之職責), 本計數器只提供
// 事後回答「那天各鍵各打了幾次」的本機證據——真要查「某家是不是被打爆了」時,
// 這是唯一的本機依據。
//
// 【於try事件記帳而非事後統計tried】即使行程被外部排程之時限中途砍掉,
// 已發出的請求仍會留下紀錄; 事後統計則會漏掉被砍那一輪的全部嘗試。
// 兩個消費端專案已各自寫過此接線(且各寫兩處), 故本函數直接提供onEvent。
//
// 【keyOf決定計帳粒度】預設記到金鑰(keyId如'agnes:agnes-2.5-flash#0'),
// 要記到條目改傳(ev)=>ev.providerId即可。
//
// 【本工廠為同步函數會throw】file與dir皆缺屬設定錯誤, 應於組裝期即失敗(fail fast),
// 與dispatchAiWkf工廠同一約定; 執行期之記帳與查詢則不throw。
/**
* 建立逐日、逐鍵之用量計數器(純觀測, 不參與任何判斷)
*
* 特點:
* onEvent可直接掛進dispatchAiFallback/dispatchAiWkf,於type為'try'時依keyOf取鍵累加——
* 嘗試時即記帳,行程被外部時限中途砍掉已發出的請求仍有紀錄;
* 逐日分桶並僅保留最近keepDays天,避免檔案無限成長;
* getDate可注入時區錨定之日期函數——預設隨系統時區,排程session之系統時區可能為UTC+0
* 而使日界錯8小時,排程環境務必注入
*
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.file=null] 輸入用量檔完整路徑字串,與dir/name二擇一
* @param {String} [opt.dir=null] 輸入狀態目錄字串
* @param {String} [opt.name='ai-usage.json'] 輸入用量檔名字串,預設'ai-usage.json'
* @param {Function} [opt.getDate=系統日期] 輸入日期函數()=>'YYYY-MM-DD',預設隨系統時區(排程環境建議注入時區錨定者)
* @param {Number} [opt.keepDays=14] 輸入保留天數正整數,預設14
* @param {Function} [opt.keyOf=(ev)=>ev.keyId||ev.providerId] 輸入計帳鍵函數(ev)=>String,決定粒度(金鑰或條目),預設優先keyId
* @returns {Object} 回傳計數器物件,內含onEvent(可直接餵dispatch之onEvent)、bump(手動累加)、today(今日統計)與file(用量檔路徑)
* @example
*
* import createUsageCounter from './src/wkf/createUsageCounter.mjs'
*
* let usage = createUsageCounter({ dir: './state' })
* //let r = await dispatchAiFallback(prompt, { providers, onEvent: usage.onEvent })
* //console.log(usage.today())
* // => { today: '2026-08-18', byKey: { 'agnes:agnes-2.5-flash#0': 3 }, total: 3 }
*
*/
function createUsageCounter(opt = {}) {
let file = get(opt, 'file', null)
if (!isestr(file)) {
let dir = get(opt, 'dir', null)
if (!isestr(dir)) {
throw new Error('createUsageCounter: file or dir is required')
}
let name = get(opt, 'name', null)
file = path.join(dir, isestr(name) ? name : 'ai-usage.json')
}
let getDate = get(opt, 'getDate', null)
if (!isfun(getDate)) {
getDate = () => {
let d = new Date()
let p2 = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`
}
}
let keepDays = get(opt, 'keepDays', null)
if (!ispint(keepDays)) {
keepDays = 14
}
let keyOf = get(opt, 'keyOf', null)
if (!isfun(keyOf)) {
keyOf = (ev) => get(ev, 'keyId', null) || get(ev, 'providerId', '')
}
let readAll = () => {
let r = fsReadJson(file)
let j = (get(r, 'error', undefined) !== undefined) ? null : get(r, 'success', null)
return isobj(j) ? j : {}
}
let bump = (key, n = 1) => {
if (!isestr(key)) {
return
}
let raw = readAll()
let today = getDate()
let cur = isobj(raw[today]) ? raw[today] : {}
cur[key] = (Number(cur[key]) || 0) + n
raw[today] = cur
//只留最近keepDays天
let days = Object.keys(raw).sort().slice(-keepDays)
let trimmed = {}
for (let d of days) {
trimmed[d] = raw[d]
}
fsWriteJson(file, trimmed, { useFormat: true })
}
return {
file,
//可直接餵dispatch之onEvent: 於type為'try'時依keyOf記帳。要同時掛自有回調, 於自有onEvent內轉呼叫本函數即可
onEvent: (ev) => {
if (get(ev, 'type', null) === 'try') {
bump(keyOf(ev))
}
},
//手動累加(不經事件之呼叫路徑用)
bump,
//今日各鍵用量統計
today: () => {
let raw = readAll()
let today = getDate()
let byKey = isobj(raw[today]) ? raw[today] : {}
let total = Object.values(byKey).reduce((a, b) => a + (Number(b) || 0), 0)
return { today, byKey, total }
},
}
}
export default createUsageCounter