dsh-superinput 0.7.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/modules/cost.js CHANGED
@@ -695,6 +695,465 @@ export const inject = []
695
695
  /** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
696
696
  const BALANCE_HOST = 'api.deepseek.com'
697
697
 
698
+ /* ------------------------------------------------------------------ */
699
+ /* Coding Plan 订阅配额(对标 dsh-cost-meter lib/coding-plans.js 的 */
700
+ /* Bearer 子集:端点/解析/降级链照抄上游,全部官方域名硬编码白名单) */
701
+ /* ------------------------------------------------------------------ */
702
+
703
+ /** 0-100 已用百分比(保留 1 位)。 */
704
+ const clampPct = (p) => Math.max(0, Math.min(100, Math.round(p * 10) / 10))
705
+
706
+ /** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
707
+ function normalizePercent(value) {
708
+ const n = Number(value)
709
+ if (!Number.isFinite(n) || n < 0) return null
710
+ return clampPct(n <= 1 ? n * 100 : n)
711
+ }
712
+
713
+ /** 归一化重置时刻:unix 秒 / 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
714
+ function normalizeResetAt(value) {
715
+ if (typeof value === 'string' && value.length > 0) {
716
+ const ms = Date.parse(value)
717
+ if (Number.isFinite(ms)) return new Date(ms).toISOString()
718
+ const asNum = Number(value)
719
+ if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
720
+ return ''
721
+ }
722
+ const n = Number(value)
723
+ if (!Number.isFinite(n) || n <= 0) return ''
724
+ return new Date(n > 1e12 ? n : n * 1000).toISOString()
725
+ }
726
+
727
+ /** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
728
+ const textWindowOf = (text) => {
729
+ const s = String(text ?? '').trim()
730
+ return s.length > 0 ? { resetsAt: '', text: s } : null
731
+ }
732
+
733
+ /** 单窗口:utilization(0-100 或 0-1)+ resets(unix 秒/毫秒/ISO)。 */
734
+ function windowOf(utilization, resetsAt) {
735
+ const percent = normalizePercent(utilization)
736
+ return percent === null ? null : { percent, resetsAt: normalizeResetAt(resetsAt) }
737
+ }
738
+
739
+ /** Anthropic:{ five_hour: { utilization, resets_at }, seven_day: {...}, ... };子配额窗口丢弃。 */
740
+ function parseAnthropicUsage(data) {
741
+ if (data === null || typeof data !== 'object') return null
742
+ const windows = {}
743
+ for (const [name, raw] of Object.entries(data)) {
744
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
745
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
746
+ if (name === 'iguana_necktie') continue
747
+ const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
748
+ if (win !== null) windows[name] = win
749
+ }
750
+ return Object.keys(windows).length > 0 ? windows : null
751
+ }
752
+
753
+ /** Z.ai / 智谱 GLM:monitor 端点 limits 数组(unit 3=小时档/6=周档)→ plans 数组 → 扁平窗口。 */
754
+ function parseZaiUsage(data) {
755
+ if (data === null || typeof data !== 'object') return null
756
+ const windows = {}
757
+ const limits = data.data !== null && typeof data.data === 'object' && Array.isArray(data.data.limits)
758
+ ? data.data.limits : null
759
+ if (limits !== null) {
760
+ const monWindows = {}
761
+ const rest = []
762
+ for (const limit of limits) {
763
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
764
+ const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
765
+ : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
766
+ ? clampPct((Number(limit.currentValue) / Number(limit.usage)) * 100)
767
+ : null
768
+ if (pct === null) continue
769
+ const resetsAt = normalizeResetAt(limit.nextResetTime)
770
+ const unit = Number(limit.unit)
771
+ if (unit === 3 && monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: pct, resetsAt }
772
+ else if (unit === 6 && monWindows.weekly === undefined) monWindows.weekly = { percent: pct, resetsAt }
773
+ else if (!Number.isFinite(unit)) rest.push({ pct, resetsAt, resetMs: Number(limit.nextResetTime) })
774
+ }
775
+ if (rest.length > 0) {
776
+ rest.sort((a, b) => {
777
+ const av = Number.isFinite(a.resetMs) && a.resetMs > 0 ? a.resetMs : 0
778
+ const bv = Number.isFinite(b.resetMs) && b.resetMs > 0 ? b.resetMs : 0
779
+ return av - bv
780
+ })
781
+ for (const item of rest) {
782
+ if (monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: item.pct, resetsAt: item.resetsAt }
783
+ else if (monWindows.weekly === undefined) monWindows.weekly = { percent: item.pct, resetsAt: item.resetsAt }
784
+ }
785
+ }
786
+ if (Object.keys(monWindows).length > 0) return monWindows
787
+ }
788
+ if (Array.isArray(data.plans)) {
789
+ for (const plan of data.plans) {
790
+ if (plan === null || typeof plan !== 'object') continue
791
+ const total = Number(plan.total_units)
792
+ const used = Number(plan.used_units)
793
+ const pct = Number.isFinite(total) && total > 0 && Number.isFinite(used)
794
+ ? clampPct((used / total) * 100)
795
+ : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
796
+ if (pct === null) continue
797
+ const spanMs = Number(plan.period_end) * 1000 - Date.now()
798
+ const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
799
+ windows[key] = { percent: pct, resetsAt: normalizeResetAt(plan.period_end) }
800
+ }
801
+ }
802
+ for (const [name, raw] of Object.entries(data)) {
803
+ if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
804
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
805
+ if (win !== null) windows[name] = win
806
+ }
807
+ return Object.keys(windows).length > 0 ? windows : null
808
+ }
809
+
810
+ /** MiniMax 单条记录的剩余百分比(优先 *_remaining_percent,total 常为 0)。 */
811
+ function remainingPercentOf(row, remainPctKey, totalKey, usedKey, remainKey) {
812
+ if (row === null || typeof row !== 'object') return null
813
+ const numeric = (key) => (row[key] == null || row[key] === '' ? NaN : Number(row[key]))
814
+ const rp = numeric(remainPctKey)
815
+ if (Number.isFinite(rp)) return Math.min(100, Math.max(0, rp <= 1 ? rp * 100 : rp))
816
+ const total = numeric(totalKey)
817
+ const used = numeric(usedKey)
818
+ const remain = numeric(remainKey)
819
+ if (Number.isFinite(total) && total > 0) {
820
+ if (Number.isFinite(remain)) return (remain / total) * 100
821
+ if (Number.isFinite(used)) return ((total - used) / total) * 100
822
+ }
823
+ return null
824
+ }
825
+
826
+ /** MiniMax 单条记录 → 5h/7d 窗口;status=3 为不限量,保留显式 ∞。 */
827
+ function windowsFromMiniMaxRecord(row) {
828
+ if (row === null || typeof row !== 'object') return {}
829
+ const windows = {}
830
+ if (Number(row.current_interval_status) === 3) {
831
+ windows['5h'] = { unlimited: true, text: '∞', resetsAt: '' }
832
+ } else {
833
+ const remain = remainingPercentOf(row, 'current_interval_remaining_percent', 'current_interval_total_count', 'current_interval_usage_count', 'current_interval_remain_count')
834
+ if (remain !== null) {
835
+ windows['5h'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.end_time ?? row.reset_time ?? row.next_reset_time) }
836
+ }
837
+ }
838
+ if (Number(row.current_weekly_status) === 3) {
839
+ windows['7d'] = { unlimited: true, text: '∞', resetsAt: '' }
840
+ } else {
841
+ const remain = remainingPercentOf(row, 'current_weekly_remaining_percent', 'current_weekly_total_count', 'current_weekly_usage_count', 'current_weekly_remain_count')
842
+ if (remain !== null) {
843
+ windows['7d'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.weekly_end_time) }
844
+ }
845
+ }
846
+ return windows
847
+ }
848
+
849
+ /** 选 MiniMax 通用额度行:general → MiniMax-M* → 第一条可解析行。 */
850
+ function pickMiniMaxModelRow(rows) {
851
+ const list = rows.filter((row) => row !== null && typeof row === 'object' && !/^(video|speech|audio|image)(?:$|[-_])/i.test(String(row.model_name ?? '')))
852
+ const byName = (name) => list.find((row) => String(row.model_name ?? '').toLowerCase() === name)
853
+ return byName('general')
854
+ ?? list.find((row) => /^minimax-m/i.test(String(row.model_name ?? '')))
855
+ ?? list.find((row) => Object.keys(windowsFromMiniMaxRecord(row)).length > 0)
856
+ ?? list[0]
857
+ ?? null
858
+ }
859
+
860
+ /** MiniMax:token_plan/remains(现行 model_remains)→ 平铺 → 旧数组形态。 */
861
+ function parseMiniMaxRemains(data) {
862
+ if (data === null || typeof data !== 'object') return null
863
+ const payload = data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data) ? data.data : data
864
+ const pickArray = (...keys) => {
865
+ for (const key of keys) {
866
+ if (Array.isArray(data?.[key])) return data[key]
867
+ if (Array.isArray(data?.data?.[key])) return data.data[key]
868
+ }
869
+ return null
870
+ }
871
+ const modelRows = pickArray('model_remains')
872
+ if (modelRows !== null) {
873
+ const fromRow = windowsFromMiniMaxRecord(pickMiniMaxModelRow(modelRows))
874
+ if (Object.keys(fromRow).length > 0) return fromRow
875
+ // 旧计数制:无 remaining_percent、靠 total>0 汇总。
876
+ let total = 0
877
+ let used = 0
878
+ let found = false
879
+ for (const item of modelRows) {
880
+ if (item === null || typeof item !== 'object') continue
881
+ const t = Number(item.current_interval_total_count ?? item.total)
882
+ const u = Number(item.current_interval_usage_count ?? item.used)
883
+ if (!Number.isFinite(t) || t <= 0) continue
884
+ found = true
885
+ total += t
886
+ used += Number.isFinite(u) ? u : 0
887
+ }
888
+ if (found && total > 0) return { current: { percent: clampPct((used / total) * 100), resetsAt: '' } }
889
+ }
890
+ const flat = windowsFromMiniMaxRecord(payload)
891
+ if (Object.keys(flat).length > 0) return flat
892
+ const windows = {}
893
+ const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
894
+ if (planRows !== null) {
895
+ planRows.forEach((row, index) => {
896
+ if (row === null || typeof row !== 'object') return
897
+ const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
898
+ const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
899
+ const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
900
+ let pct = null
901
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
902
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
903
+ else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
904
+ if (pct === null) return
905
+ const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
906
+ const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : `window${index + 1}`
907
+ windows[label] = { percent: clampPct(pct), resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at) }
908
+ })
909
+ }
910
+ return Object.keys(windows).length > 0 ? windows : null
911
+ }
912
+
913
+ /** Kimi Code 订阅配额(api.kimi.com/coding/v1/usages,Kimi CLI 同款接口)。 */
914
+ function parseKimiCodingUsage(data) {
915
+ if (data === null || typeof data !== 'object') return null
916
+ const payload = data.data && typeof data.data === 'object' ? data.data : data
917
+ const quota = payload.quota && typeof payload.quota === 'object' ? payload.quota : payload
918
+ const windows = {}
919
+ const numeric = (value) => (value == null || typeof value === 'boolean' || String(value).trim() === '' ? NaN : Number(value))
920
+ const counter = (row) => {
921
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null
922
+ const total = numeric(row.limit)
923
+ const used = numeric(row.used)
924
+ const remaining = numeric(row.remaining)
925
+ const pct = total > 0 && Number.isFinite(total)
926
+ ? Number.isFinite(used) && used >= 0 ? (used / total) * 100
927
+ : Number.isFinite(remaining) && remaining >= 0 ? ((total - remaining) / total) * 100 : NaN
928
+ : NaN
929
+ return Number.isFinite(pct) ? { percent: clampPct(pct), resetsAt: normalizeResetAt(row.resetTime ?? row.reset_time ?? row.resetAt ?? row.resetsAt) } : null
930
+ }
931
+ const usages = quota.usages ?? {}
932
+ const shapes = [['5h', ['limit_5h', 'limit5h']], ['weekly', ['limit_7d', 'limit7d']], ['monthly', ['limit_month_total', 'limitMonthTotal']]]
933
+ for (const [name, aliases] of shapes) {
934
+ const raw = aliases.map((key) => usages[key]).find((row) => row && typeof row === 'object' && !Array.isArray(row))
935
+ if (!raw) continue
936
+ const counted = counter(raw)
937
+ const ratio = numeric(raw.usedRatio ?? raw.used_ratio)
938
+ if (counted) windows[name] = counted
939
+ else if (Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
940
+ windows[name] = { percent: clampPct(ratio * 100), resetsAt: normalizeResetAt(raw.resetTime ?? raw.reset_time ?? raw.resetAt) }
941
+ }
942
+ }
943
+ const usage = counter(quota.usage)
944
+ if (usage && (!Object.keys(usages).length || usages.limit_7d || usages.limit7d)) windows.weekly = usage
945
+ const limits = Array.isArray(quota.limits) ? quota.limits : []
946
+ limits.forEach((row, index) => {
947
+ const parsed = counter(row?.detail)
948
+ if (!parsed) return
949
+ const duration = numeric(row.window?.duration)
950
+ const unitRaw = String(row.window?.timeUnit ?? '').toLowerCase().replace(/^time_unit_/, '')
951
+ const unit = unitRaw.startsWith('hour') ? 'h' : unitRaw.startsWith('day') ? 'd'
952
+ : unitRaw.startsWith('week') ? 'w' : unitRaw.startsWith('minute') ? 'm'
953
+ : unitRaw.startsWith('month') ? 'mo' : unitRaw.slice(0, 4)
954
+ const name = unit === 'm' && duration === 300 ? '5h'
955
+ : Number.isFinite(duration) && duration > 0 && unit.length > 0 ? `${duration}${unit}` : `window${index + 1}`
956
+ windows[name] = parsed
957
+ })
958
+ return Object.keys(windows).length > 0 ? windows : null
959
+ }
960
+
961
+ /** Kimi / Moonshot PAYG 余额(人民币分;<100 视为已是元)。 */
962
+ function parseKimiBalance(data) {
963
+ if (data === null || typeof data !== 'object') return null
964
+ const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
965
+ const n = Number(raw)
966
+ if (!Number.isFinite(n) || n < 0) return null
967
+ const cny = n >= 100 ? n / 100 : n
968
+ const win = textWindowOf(`¥${(Math.round(cny * 100) / 100).toFixed(2)}`)
969
+ return win === null ? null : { balance: win }
970
+ }
971
+
972
+ /** OpenRouter:{ data: { total_credits, total_usage } }(美元)→ 已用%。 */
973
+ function parseOpenRouterCredits(data) {
974
+ if (data === null || typeof data !== 'object') return null
975
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
976
+ const total = Number(d.total_credits ?? d.credits)
977
+ const used = Number(d.total_usage ?? d.usage)
978
+ if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
979
+ return { credits: { percent: clampPct((used / total) * 100), resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
980
+ }
981
+
982
+ /** SiliconFlow:balance/amount/remain(人民币)→ 文本窗口。 */
983
+ function parseSiliconFlowInfo(data) {
984
+ if (data === null || typeof data !== 'object') return null
985
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
986
+ const n = Number(d.balance ?? d.amount ?? d.remain ?? d.remaining)
987
+ if (!Number.isFinite(n) || n < 0) return null
988
+ const win = textWindowOf(`¥${(Math.round(n * 100) / 100).toFixed(2)}`)
989
+ return win === null ? null : { balance: win }
990
+ }
991
+
992
+ /** CommandCode:windowLimits(used/cap)+ 月度 Credits 余额文本。 */
993
+ function parseCommandCodeCredits(data) {
994
+ if (data === null || typeof data !== 'object') return null
995
+ const windows = {}
996
+ const limits = data.windowLimits
997
+ if (limits !== null && typeof limits === 'object' && !Array.isArray(limits)) {
998
+ for (const [name, raw] of Object.entries(limits)) {
999
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
1000
+ const used = Number(raw.used)
1001
+ const cap = Number(raw.cap)
1002
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0 || used < 0) continue
1003
+ windows[name] = { percent: clampPct((used / cap) * 100), resetsAt: normalizeResetAt(raw.resetAt) }
1004
+ }
1005
+ }
1006
+ const monthly = Number(data.credits !== null && typeof data.credits === 'object' ? data.credits.monthlyCredits : undefined)
1007
+ if (Number.isFinite(monthly) && monthly >= 0) {
1008
+ const win = textWindowOf(`$${(Math.round(monthly * 100) / 100).toFixed(2)}`)
1009
+ if (win !== null) windows.monthly = win
1010
+ }
1011
+ return Object.keys(windows).length > 0 ? windows : null
1012
+ }
1013
+
1014
+ /** Cline Pass:limits[].percentUsed 已是 0-100 已用百分比,不走 0-1 猜测。 */
1015
+ function parseClinePassUsage(data) {
1016
+ if (data === null || typeof data !== 'object') return null
1017
+ const limits = data.data !== null && typeof data.data === 'object' ? data.data.limits : undefined
1018
+ if (!Array.isArray(limits)) return null
1019
+ const names = new Map([['five_hour', 'fiveHour'], ['weekly', 'weekly'], ['monthly', 'monthly']])
1020
+ const windows = {}
1021
+ for (const entry of limits) {
1022
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
1023
+ const name = names.get(entry.type)
1024
+ const raw = entry.percentUsed
1025
+ const percent = typeof raw === 'number'
1026
+ ? raw
1027
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim()) ? Number(raw) : Number.NaN
1028
+ if (name === undefined || !Number.isFinite(percent) || percent < 0) continue
1029
+ windows[name] = { percent: clampPct(percent), resetsAt: normalizeResetAt(entry.resetsAt) }
1030
+ }
1031
+ return Object.keys(windows).length > 0 ? windows : null
1032
+ }
1033
+
1034
+ const CODING_PLAN_PARSERS = {
1035
+ anthropic: parseAnthropicUsage,
1036
+ zai: parseZaiUsage,
1037
+ minimax: parseMiniMaxRemains,
1038
+ openrouter: parseOpenRouterCredits,
1039
+ siliconflow: parseSiliconFlowInfo,
1040
+ commandcode: parseCommandCodeCredits,
1041
+ clinepass: parseClinePassUsage,
1042
+ }
1043
+
1044
+ /**
1045
+ * 官方端点白名单(硬编码,配置不可改;MiniMax 是唯一例外——允许显式 HTTPS
1046
+ * origin 覆盖,且经 normalizeMiniMaxOrigin 校验为公网域名)。顺序即降级链:
1047
+ * 认证失败(401/403)与解析成功立即返回,其余错误换下一端点。
1048
+ */
1049
+ const CODING_PLAN_ENDPOINTS = {
1050
+ anthropic: ['https://api.anthropic.com/api/oauth/usage'],
1051
+ zai: [
1052
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
1053
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
1054
+ 'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1055
+ 'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1056
+ ],
1057
+ minimax: [
1058
+ 'https://www.minimax.cn/v1/token_plan/remains',
1059
+ 'https://www.minimax.io/v1/token_plan/remains',
1060
+ 'https://www.minimaxi.com/v1/token_plan/remains',
1061
+ ],
1062
+ kimi: [
1063
+ 'https://api.kimi.com/coding/v1/usages',
1064
+ 'https://api.kimi.com/coding/v1/usage',
1065
+ 'https://api.moonshot.cn/v1/users/me/balance',
1066
+ ],
1067
+ openrouter: ['https://openrouter.ai/api/v1/credits'],
1068
+ siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
1069
+ commandcode: ['https://api.commandcode.ai/alpha/billing/credits'],
1070
+ clinepass: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1071
+ }
1072
+
1073
+ const CODING_PLAN_IDS = Object.keys(CODING_PLAN_ENDPOINTS)
1074
+
1075
+ /**
1076
+ * MiniMax 自定源归一化:只接受 HTTPS origin(无路径/凭据/空白),并拒绝
1077
+ * 环回/内网/链路本地/裸 IP —— SSRF 硬化比上游更严(上游只查 HTTPS origin)。
1078
+ */
1079
+ function normalizeMiniMaxOrigin(value) {
1080
+ if (value === undefined || value === null || value === '') return ''
1081
+ if (typeof value !== 'string' || value.length > 2048) return null
1082
+ const text = value.trim()
1083
+ if (!text) return ''
1084
+ if (!/^https:\/\/[^/?#]+\/?$/i.test(text) || /[\s\\@?#]/.test(text)) return null
1085
+ let url
1086
+ try { url = new URL(text) } catch { return null }
1087
+ const host = url.hostname.toLowerCase()
1088
+ if (!host || url.username || url.password || url.pathname !== '/') return null
1089
+ if (host === '::1' || host.endsWith('.local') || host.endsWith('.internal')) return null
1090
+ if (/^(localhost|0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return null
1091
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host) && host.includes(':')) return null
1092
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null
1093
+ return url.origin
1094
+ }
1095
+
1096
+ /** 单端点抓取:Bearer 鉴权;Kimi 订阅端点须带官方 CLI 的 UA(上游实测约束)。 */
1097
+ async function fetchPlanJson(url, key, { kimiCoding = false, redirect } = {}) {
1098
+ const headers = {
1099
+ authorization: `Bearer ${key.trim()}`,
1100
+ accept: 'application/json',
1101
+ 'user-agent': 'dsh-cost (DeepSeek Harness plugin)',
1102
+ }
1103
+ if (kimiCoding) headers['user-agent'] = 'KimiCLI/1.6'
1104
+ return fetch(url, { headers, redirect, signal: AbortSignal.timeout(15_000) })
1105
+ }
1106
+
1107
+ /** 响应体有界 JSON(256KiB 上限,防异常端点拖内存)。 */
1108
+ async function readJsonBounded(res, cap = 262_144) {
1109
+ const text = await res.text()
1110
+ if (text.length > cap) throw new Error('response too large')
1111
+ return JSON.parse(text)
1112
+ }
1113
+
1114
+ /**
1115
+ * 查询单家 Coding Plan 额度:按白名单端点顺序尝试。401/403 与解析成功立即
1116
+ * 定论,其余错误换下一端点;zai/minimax 双域 Key 不互通、kimi 订阅端点 401
1117
+ * 视为「非订阅 Key」继续降级到 PAYG 余额端点(上游 issue #42/#53 同语义)。
1118
+ * 全部错误均为 soft(未订阅/Key 无效属预期场景,面板中性提示)。
1119
+ */
1120
+ async function queryCodingPlan(provider, key, origin) {
1121
+ const urls = provider === 'minimax'
1122
+ ? (() => {
1123
+ const base = normalizeMiniMaxOrigin(origin)
1124
+ if (base === null) throw Object.assign(new Error('minimax: invalid HTTPS origin'), { soft: true })
1125
+ return base ? [`${base}/v1/token_plan/remains`, `${base}/v1/api/openplatform/coding_plan/remains`] : CODING_PLAN_ENDPOINTS.minimax
1126
+ })()
1127
+ : CODING_PLAN_ENDPOINTS[provider]
1128
+ let lastError = 'HTTP error'
1129
+ for (const url of urls) {
1130
+ const kimiCoding = provider === 'kimi' && new URL(url).hostname === 'api.kimi.com'
1131
+ let res
1132
+ try {
1133
+ res = await fetchPlanJson(url, key, {
1134
+ kimiCoding,
1135
+ redirect: provider === 'minimax' ? 'error' : provider === 'zai' ? 'manual' : undefined,
1136
+ })
1137
+ } catch (error) {
1138
+ lastError = String(error?.message ?? error).slice(0, 120)
1139
+ continue
1140
+ }
1141
+ if (res.status === 401 || res.status === 403) {
1142
+ lastError = `HTTP ${res.status}`
1143
+ if (provider === 'zai' || provider === 'minimax' || kimiCoding) continue
1144
+ throw Object.assign(new Error(lastError), { soft: true })
1145
+ }
1146
+ if (!res.ok) { lastError = `HTTP ${res.status}`; continue }
1147
+ let data = null
1148
+ try { data = await readJsonBounded(res) } catch { lastError = 'invalid JSON response'; continue }
1149
+ const windows = (kimiCoding ? parseKimiCodingUsage(data) : CODING_PLAN_PARSERS[provider](data))
1150
+ if (windows) return { windows, endpoint: url }
1151
+ lastError = 'no usage windows in response'
1152
+ }
1153
+ throw Object.assign(new Error(lastError), { soft: true })
1154
+ }
1155
+
1156
+
698
1157
  export const Config = Schema.object({
699
1158
  enabled: Schema.boolean().default(true).description('是否启用费用统计'),
700
1159
  /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
@@ -712,6 +1171,8 @@ export const Config = Schema.object({
712
1171
  peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
713
1172
  /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
714
1173
  apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
1174
+ /** Coding Plan 订阅配额:{ 厂商: { key, origin? } },Key 在费用设置页配置、不回显。 */
1175
+ codingPlans: Schema.object({}).default({}).description('Coding Plan 订阅配额(在费用设置页配置)'),
715
1176
  })
716
1177
 
717
1178
  export function apply(ctx, config) {
@@ -723,7 +1184,7 @@ export function apply(ctx, config) {
723
1184
  if (!live.enabled) return
724
1185
 
725
1186
  // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
726
- const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey']
1187
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey', 'codingPlans']
727
1188
  const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
728
1189
  try {
729
1190
  const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
@@ -745,9 +1206,38 @@ export function apply(ctx, config) {
745
1206
  }, 400)
746
1207
  }
747
1208
 
1209
+ /* -- Coding Plan 配额缓存与查询编排 -------------------------------- */
1210
+
1211
+ const PLAN_TTL_MS = 600_000
1212
+ const planCache = new Map() // provider → { at, result }
1213
+ const planKeyOf = (id) => {
1214
+ const entry = live.codingPlans?.[id]
1215
+ const key = typeof entry?.key === 'string' ? entry.key.trim() : ''
1216
+ return key.length > 0 && key.length <= 4096 ? key : ''
1217
+ }
1218
+ async function refreshCodingPlan(id, force = false) {
1219
+ const key = planKeyOf(id)
1220
+ if (!key) return { hasKey: false }
1221
+ const cached = planCache.get(id)
1222
+ if (!force && cached && Date.now() - cached.at < PLAN_TTL_MS) return cached.result
1223
+ let result
1224
+ try {
1225
+ const { windows } = await queryCodingPlan(id, key, live.codingPlans?.[id]?.origin)
1226
+ result = { hasKey: true, windows, at: Date.now() }
1227
+ void report(`coding plan ${id} refreshed: ${Object.keys(windows).join(',')}`)
1228
+ } catch (error) {
1229
+ result = { hasKey: true, error: String(error?.message ?? error).slice(0, 160), at: Date.now() }
1230
+ void report(`coding plan ${id} refresh failed: ${result.error}`)
1231
+ }
1232
+ planCache.set(id, { at: Date.now(), result })
1233
+ return result
1234
+ }
1235
+
748
1236
  const lockPath = join(STORE_DIR, 'ledger.lock')
1237
+ // 存储目录可能不存在(全新安装):锁文件 openSync(wx) 要求父目录已建。
1238
+ try { mkdirSync(STORE_DIR, { recursive: true }) } catch { /* ignore */ }
749
1239
  const lock = acquireLedgerLock(lockPath)
750
- void report(`server boot v0.8.2 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'}`)
1240
+ void report(`server boot v0.10.0 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'} plans=${CODING_PLAN_IDS.filter((id) => planKeyOf(id)).length}`)
751
1241
 
752
1242
  const ledger = createLedger(live.retentionDays)
753
1243
  if (lock.owner) ledger.load()
@@ -1130,7 +1620,7 @@ export function apply(ctx, config) {
1130
1620
  const daysLeft = daysInMonth - nowDate.getDate()
1131
1621
  const projected = monthCost + avgDaily * daysLeft
1132
1622
 
1133
- return json({
1623
+ send(200, {
1134
1624
  monthCost,
1135
1625
  monthCalls,
1136
1626
  avgDaily,
@@ -1142,6 +1632,7 @@ export function apply(ctx, config) {
1142
1632
  basis: n >= 3 ? 'recent' : 'insufficient',
1143
1633
  recent,
1144
1634
  })
1635
+ return
1145
1636
  }
1146
1637
 
1147
1638
  if (path === '/calls' && req.method === 'GET') {
@@ -1316,6 +1807,83 @@ export function apply(ctx, config) {
1316
1807
  return
1317
1808
  }
1318
1809
 
1810
+ // 热力图:近 N 天逐日 { cost, calls, tokens }(GitHub 贡献图样式)。
1811
+ if (path === '/heatmap' && req.method === 'GET') {
1812
+ const url = new URL(req.url ?? '/api/dsh-cost/heatmap', 'http://localhost')
1813
+ const days = Math.min(365, Math.max(28, Number(url.searchParams.get('days') ?? 182)))
1814
+ const snap = await ledgerSnapshotFresh()
1815
+ const since = localDayKey(Date.now() - days * 86400000)
1816
+ const rows = []
1817
+ for (const key of Object.keys(snap.days).sort()) {
1818
+ if (key < since) continue
1819
+ const day = snap.days[key]
1820
+ rows.push({
1821
+ date: key,
1822
+ cost: day.cost ?? 0,
1823
+ calls: day.calls ?? 0,
1824
+ tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) + (day.reasoning ?? 0),
1825
+ })
1826
+ }
1827
+ send(200, { days: rows })
1828
+ return
1829
+ }
1830
+
1831
+ // Coding Plan 配额:只下发窗口/错误/时间戳,Key 永不出服务端。
1832
+ const planState = () => {
1833
+ const providers = {}
1834
+ for (const id of CODING_PLAN_IDS) providers[id] = { hasKey: planKeyOf(id).length > 0 }
1835
+ for (const id of CODING_PLAN_IDS) {
1836
+ if (!providers[id].hasKey) continue
1837
+ const cached = planCache.get(id)
1838
+ if (cached) providers[id] = { ...providers[id], ...cached.result, stale: Date.now() - cached.at >= PLAN_TTL_MS }
1839
+ }
1840
+ return providers
1841
+ }
1842
+ if (path === '/coding-plans' && req.method === 'GET') {
1843
+ // 已配置且缓存过期的后台补刷:GET 不阻塞,下一轮轮询取到新值。
1844
+ for (const id of CODING_PLAN_IDS) {
1845
+ if (!planKeyOf(id)) continue
1846
+ const cached = planCache.get(id)
1847
+ if (!cached || Date.now() - cached.at >= PLAN_TTL_MS) void refreshCodingPlan(id)
1848
+ }
1849
+ send(200, { providers: planState() })
1850
+ return
1851
+ }
1852
+ if (path === '/coding-plans/refresh' && req.method === 'POST') {
1853
+ let raw = ''
1854
+ for await (const chunk of req) raw += chunk
1855
+ let body = {}
1856
+ try { body = raw ? JSON.parse(raw) : {} } catch { send(400, { error: 'invalid-json' }); return }
1857
+ const only = typeof body.provider === 'string' ? body.provider : ''
1858
+ const ids = only ? [only] : CODING_PLAN_IDS.filter((id) => planKeyOf(id))
1859
+ for (const id of ids) await refreshCodingPlan(id, true)
1860
+ send(200, { ok: true, providers: planState() })
1861
+ return
1862
+ }
1863
+ if (path === '/coding-plans/config' && req.method === 'POST') {
1864
+ let raw = ''
1865
+ for await (const chunk of req) raw += chunk
1866
+ let body
1867
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1868
+ const id = typeof body.provider === 'string' ? body.provider : ''
1869
+ if (!CODING_PLAN_IDS.includes(id)) { send(400, { error: 'unknown-provider' }); return }
1870
+ const key = typeof body.key === 'string' ? body.key.trim().slice(0, 4096) : ''
1871
+ const merged = { ...(live.codingPlans ?? {}) }
1872
+ if (key) {
1873
+ const origin = normalizeMiniMaxOrigin(typeof body.origin === 'string' ? body.origin : undefined)
1874
+ if (origin === null) { send(400, { error: 'invalid-origin' }); return }
1875
+ merged[id] = origin ? { key, origin } : { key }
1876
+ } else {
1877
+ delete merged[id] // 空 key = 清除配置
1878
+ }
1879
+ live.codingPlans = merged
1880
+ planCache.delete(id)
1881
+ persistSettings()
1882
+ void report(`coding plan config updated: ${id} ${key ? 'set' : 'cleared'}`)
1883
+ send(200, { ok: true })
1884
+ return
1885
+ }
1886
+
1319
1887
  send(404, { error: 'not-found' })
1320
1888
  },
1321
1889
  }), 'dsh-cost: /api/dsh-cost/*')