@twinsearth/dsh-master 3.2.9 → 3.3.0
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/README.md +39 -6
- package/client.js +51 -1
- package/dist-host/cost/attribution.js +183 -0
- package/dist-host/cost/audit.js +163 -0
- package/dist-host/cost/budget.js +211 -0
- package/dist-host/cost/bus.js +95 -0
- package/dist-host/cost/cert-chain.js +100 -0
- package/dist-host/cost/experiment.js +132 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +312 -0
- package/dist-host/cost/ledger-report.js +193 -0
- package/dist-host/cost/ledger.js +401 -0
- package/dist-host/cost/metrics.js +198 -0
- package/dist-host/cost/milestones.js +171 -0
- package/dist-host/cost/modules/index.js +5 -0
- package/dist-host/cost/modules/manifests.js +130 -0
- package/dist-host/cost/policy.js +276 -0
- package/dist-host/cost/report.js +156 -0
- package/dist-host/cost/reuse-report.js +128 -0
- package/dist-host/cost/rsi.js +367 -0
- package/dist-host/cost/toolface.js +147 -0
- package/dist-host/cost/types.js +31 -0
- package/dist-host/kernel.js +38 -11
- package/dist-host/plugins/cost-optimizer.js +277 -27
- package/dist-host/plugins/superagent-router.js +89 -2
- package/dist-host/plugins/superagent-worker.js +1 -1
- package/dist-host/superagent/router.js +1 -1
- package/dist-host/superagent/types.js +8 -0
- package/dist-worker/agent-loop.js +449 -7
- package/dist-worker/baseline-fit.js +66 -0
- package/dist-worker/budget-guard.js +93 -0
- package/dist-worker/ccs.js +99 -0
- package/dist-worker/cost-hooks.js +115 -0
- package/dist-worker/cost-modules.js +330 -0
- package/dist-worker/cost-spi.js +3 -0
- package/dist-worker/edg.js +372 -0
- package/dist-worker/gdr.js +159 -0
- package/dist-worker/index.js +80 -2
- package/dist-worker/llm-http.js +20 -0
- package/dist-worker/sink-http.js +1 -0
- package/dist-worker/spill.js +115 -0
- package/dist-worker/tool-batch.js +66 -0
- package/dist-worker/tool-compact.js +275 -0
- package/dist-worker/tool-disclosure.js +90 -0
- package/dist-worker/tools.js +16 -0
- package/docs/ARCHITECTURE.md +32 -11
- package/docs/CHANGELOG.md +60 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
- package/docs/EXPERIMENT-M5-real-ab.md +82 -0
- package/docs/RELEASE-v3.2.10.md +32 -0
- package/docs/ROADMAP.md +3 -3
- package/docs/SECURITY.md +9 -9
- package/docs/SUPERAGENT.md +5 -5
- package/docs/THIRD-PARTY.md +87 -0
- package/docs/WORKER-AGENT-LOOP.md +1 -1
- package/package.json +15 -3
- package/src/cost/attribution.ts +186 -0
- package/src/cost/audit.ts +170 -0
- package/src/cost/budget.ts +226 -0
- package/src/cost/bus.ts +97 -0
- package/src/cost/cert-chain.ts +102 -0
- package/src/cost/experiment.ts +136 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +316 -0
- package/src/cost/ledger-report.ts +186 -0
- package/src/cost/ledger.ts +385 -0
- package/src/cost/metrics.ts +193 -0
- package/src/cost/milestones.ts +170 -0
- package/src/cost/modules/index.ts +13 -0
- package/src/cost/modules/manifests.ts +129 -0
- package/src/cost/policy.ts +276 -0
- package/src/cost/report.ts +158 -0
- package/src/cost/reuse-report.ts +131 -0
- package/src/cost/rsi.ts +364 -0
- package/src/cost/toolface.ts +151 -0
- package/src/cost/types.ts +298 -0
- package/src/kernel.ts +37 -11
- package/src/plugins/cost-optimizer.ts +271 -26
- package/src/plugins/superagent-router.ts +83 -2
- package/src/plugins/superagent-worker.ts +1 -1
- package/src/superagent/router.ts +1 -1
- package/src/superagent/types.ts +70 -0
- package/worker/agent-loop.ts +444 -7
- package/worker/baseline-fit.ts +80 -0
- package/worker/budget-guard.ts +156 -0
- package/worker/ccs.ts +202 -0
- package/worker/cost-hooks.ts +133 -0
- package/worker/cost-modules.ts +356 -0
- package/worker/cost-spi.ts +283 -0
- package/worker/edg.ts +424 -0
- package/worker/gdr.ts +233 -0
- package/worker/index.ts +84 -3
- package/worker/llm-http.ts +18 -0
- package/worker/sink-http.ts +5 -2
- package/worker/spill.ts +133 -0
- package/worker/tool-batch.ts +124 -0
- package/worker/tool-compact.ts +317 -0
- package/worker/tool-disclosure.ts +131 -0
- package/worker/tools.ts +553 -531
- package/worker/types.ts +191 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 3.3.0 系统层 · audit(哈希链审计,设计 §5.5)。
|
|
7
|
+
*
|
|
8
|
+
* 结构约束:
|
|
9
|
+
* * **只追加**:没有 update/delete/rewrite API,记录 `Object.freeze`;
|
|
10
|
+
* * 每条记录含 `prevHash` 与自身 `hash`,任何篡改或截断都会在 `verify()` 断链;
|
|
11
|
+
* * 每次替换写一条 `SubstitutionCert`(edgeId / toolName / schemaHash / guardExpr /
|
|
12
|
+
* receiptDigest / replacedCallbackId / viaNativeDispatcher: true),
|
|
13
|
+
* 并与 ledger 事件用同一 `taskId + step` 关联 —— 报表可回放「这一省是怎么来的」。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
/** SubstitutionCert 必填字段;缺任何一项都不得落链(否则证书无法复核)。 */
|
|
19
|
+
const CERT_REQUIRED = Object.freeze([
|
|
20
|
+
'edgeId',
|
|
21
|
+
'toolName',
|
|
22
|
+
'schemaHash',
|
|
23
|
+
'guardExpr',
|
|
24
|
+
'receiptDigest',
|
|
25
|
+
'replacedCallbackId',
|
|
26
|
+
'taskId',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function canonical(value) {
|
|
30
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonical(item)).join(',')}]`
|
|
31
|
+
if (value && typeof value === 'object') {
|
|
32
|
+
const keys = Object.keys(value).sort()
|
|
33
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null'
|
|
36
|
+
if (value === undefined) return 'null'
|
|
37
|
+
return JSON.stringify(value) ?? 'null'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hashOf(seq, at, type, actor, payload, prevHash) {
|
|
41
|
+
const body = canonical([seq, at, type, actor, payload, prevHash])
|
|
42
|
+
return `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{now?: () => number, maxRecords?: number}} [options]
|
|
47
|
+
*/
|
|
48
|
+
export function createAuditChain(options = {}) {
|
|
49
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
50
|
+
const maxRecords = Math.max(1, Number(options.maxRecords ?? 2000) || 2000)
|
|
51
|
+
const records = []
|
|
52
|
+
let seq = 0
|
|
53
|
+
let dropped = 0
|
|
54
|
+
|
|
55
|
+
function append({ type, actor = 'cost-system', payload = {} } = {}) {
|
|
56
|
+
seq += 1
|
|
57
|
+
const at = now()
|
|
58
|
+
const prevHash = records.length > 0 ? records[records.length - 1].hash : ''
|
|
59
|
+
const record = Object.freeze({
|
|
60
|
+
seq,
|
|
61
|
+
at,
|
|
62
|
+
type: String(type ?? 'unknown'),
|
|
63
|
+
actor: String(actor ?? 'cost-system'),
|
|
64
|
+
payload: Object.freeze({ ...payload }),
|
|
65
|
+
prevHash,
|
|
66
|
+
hash: hashOf(seq, at, String(type ?? 'unknown'), String(actor ?? 'cost-system'), payload, prevHash),
|
|
67
|
+
})
|
|
68
|
+
records.push(record)
|
|
69
|
+
if (records.length > maxRecords) {
|
|
70
|
+
records.shift()
|
|
71
|
+
dropped += 1
|
|
72
|
+
}
|
|
73
|
+
return record
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 写一条 SubstitutionCert(§5.5)。fail-closed:
|
|
78
|
+
* * 必填字段缺失/空白 ⇒ 拒绝;
|
|
79
|
+
* * `viaNativeDispatcher` 必须严格为 `true`(非原生派发不得替换);
|
|
80
|
+
* * `taskId + step` 是回放键,step 必须是非负整数。
|
|
81
|
+
*/
|
|
82
|
+
function appendSubstitution(cert = {}) {
|
|
83
|
+
const missing = CERT_REQUIRED.filter((key) => {
|
|
84
|
+
const value = cert?.[key]
|
|
85
|
+
return value === undefined || value === null || String(value).trim() === ''
|
|
86
|
+
})
|
|
87
|
+
if (missing.length > 0) {
|
|
88
|
+
return { ok: false, code: 'cert-invalid', message: `substitution cert missing ${missing.join(',')}`, missing }
|
|
89
|
+
}
|
|
90
|
+
if (cert.viaNativeDispatcher !== true) {
|
|
91
|
+
return { ok: false, code: 'cert-not-native', message: 'substitution must be executed via the native dispatcher' }
|
|
92
|
+
}
|
|
93
|
+
const step = Number(cert.step)
|
|
94
|
+
if (!Number.isInteger(step) || step < 0) {
|
|
95
|
+
return { ok: false, code: 'cert-step-invalid', message: 'step must be a non-negative integer' }
|
|
96
|
+
}
|
|
97
|
+
const savedTokens = Number(cert.savedTokens ?? 0)
|
|
98
|
+
if (!Number.isFinite(savedTokens) || savedTokens < 0) {
|
|
99
|
+
return { ok: false, code: 'cert-saved-invalid', message: 'savedTokens must be a non-negative number' }
|
|
100
|
+
}
|
|
101
|
+
const payload = Object.freeze({
|
|
102
|
+
kind: 'substitution-cert',
|
|
103
|
+
edgeId: String(cert.edgeId),
|
|
104
|
+
toolName: String(cert.toolName),
|
|
105
|
+
schemaHash: String(cert.schemaHash),
|
|
106
|
+
guardExpr: String(cert.guardExpr),
|
|
107
|
+
receiptDigest: String(cert.receiptDigest),
|
|
108
|
+
replacedCallbackId: String(cert.replacedCallbackId),
|
|
109
|
+
/** M4:落链即代表「已经过原生派发器真的派发」;证书链核对直接读这一位。 */
|
|
110
|
+
dispatched: true,
|
|
111
|
+
viaNativeDispatcher: true,
|
|
112
|
+
taskId: String(cert.taskId),
|
|
113
|
+
step,
|
|
114
|
+
forkId: cert.forkId === undefined ? null : String(cert.forkId),
|
|
115
|
+
sessionId: cert.sessionId === undefined ? null : String(cert.sessionId),
|
|
116
|
+
ticketId: cert.ticketId === undefined ? null : String(cert.ticketId),
|
|
117
|
+
moduleId: cert.moduleId === undefined ? null : String(cert.moduleId),
|
|
118
|
+
savedTokens,
|
|
119
|
+
})
|
|
120
|
+
const record = append({ type: 'cost.substitution', actor: 'cost-ccs', payload })
|
|
121
|
+
return { ok: true, cert: payload, record }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function verify(source) {
|
|
125
|
+
const list = Array.isArray(source) ? source : records
|
|
126
|
+
let prevHash = ''
|
|
127
|
+
let expectedSeq = 1
|
|
128
|
+
for (const record of list) {
|
|
129
|
+
if (record.seq !== expectedSeq) return { ok: false, count: list.length, brokenAt: record.seq, reason: 'seq-gap' }
|
|
130
|
+
if (record.prevHash !== prevHash) return { ok: false, count: list.length, brokenAt: record.seq, reason: 'prev-hash-mismatch' }
|
|
131
|
+
const expected = hashOf(record.seq, record.at, record.type, record.actor, record.payload, record.prevHash)
|
|
132
|
+
if (record.hash !== expected) return { ok: false, count: list.length, brokenAt: record.seq, reason: 'hash-mismatch' }
|
|
133
|
+
prevHash = record.hash
|
|
134
|
+
expectedSeq += 1
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, count: list.length, brokenAt: null, reason: null }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function snapshot(limit = 50) {
|
|
140
|
+
const n = Math.max(1, Number(limit) || 50)
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
ok: true,
|
|
143
|
+
count: records.length,
|
|
144
|
+
dropped,
|
|
145
|
+
verified: verify(),
|
|
146
|
+
records: records.slice(-n),
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function reset() {
|
|
151
|
+
records.length = 0
|
|
152
|
+
seq = 0
|
|
153
|
+
dropped = 0
|
|
154
|
+
return { ok: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 取某任务/步骤的证书(回放用;不改链)。 */
|
|
158
|
+
function substitutions(filter = {}) {
|
|
159
|
+
const taskId = filter.taskId === undefined ? null : String(filter.taskId)
|
|
160
|
+
const step = filter.step === undefined ? null : Number(filter.step)
|
|
161
|
+
const rows = records
|
|
162
|
+
.filter((record) => record.type === 'cost.substitution')
|
|
163
|
+
.filter((record) => (taskId === null ? true : record.payload.taskId === taskId))
|
|
164
|
+
.filter((record) => (step === null ? true : record.payload.step === step))
|
|
165
|
+
return Object.freeze(rows.map((record) => Object.freeze({ ...record.payload, seq: record.seq, at: record.at, hash: record.hash })))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 只追加:对外**不暴露**任何修改/删除入口(records() 返回副本)。
|
|
169
|
+
return Object.freeze({ append, appendSubstitution, verify, snapshot, reset, records: () => records.slice(), substitutions })
|
|
170
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 3.3.0 系统层 · budget(预算闸,设计 §5.2)。
|
|
7
|
+
*
|
|
8
|
+
* 规格要点:
|
|
9
|
+
* * 规则:`unit(task|fork|day) + limitTokens? + limitCost? + onExhaust(hard-stop|degrade)`;
|
|
10
|
+
* * **检查点只在 ① beforeModelCall**(`authorize()` 即该检查点,其它地方不得自行扣减);
|
|
11
|
+
* * 超限返回 `masterError('budget-exhausted', …)` 形状 + 结构化 `remaining`;
|
|
12
|
+
* * **fail-closed**:额度不足不得「先跑着看」——拒绝时**不扣额度**、不放行;
|
|
13
|
+
* * `degrade` 档只允许降级到**已在白名单**的廉价模型,绝不引入新 provider。
|
|
14
|
+
*
|
|
15
|
+
* 成本维度(`limitCost`)需要调用方给出本次预估成本(`cost` 字段);
|
|
16
|
+
* 未提供成本时成本维度不可判定,会显式标 `costTracked: false`,而不是假装达标。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DEFAULT_BUDGET_LIMITS } from './types.ts'
|
|
20
|
+
|
|
21
|
+
const UNITS = ['task', 'fork', 'day']
|
|
22
|
+
const EXHAUST_ACTIONS = ['hard-stop', 'degrade']
|
|
23
|
+
|
|
24
|
+
/** 与 kernel.masterError 同形状(host 侧不反向依赖 kernel,避免子系统耦合)。 */
|
|
25
|
+
function masterError(code, message, detail) {
|
|
26
|
+
const out = { ok: false, code: String(code), error: String(message ?? code) }
|
|
27
|
+
if (detail !== undefined) out.detail = detail
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function positiveLimit(value) {
|
|
32
|
+
const n = Number(value)
|
|
33
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function keyOf(unit, key) {
|
|
37
|
+
return `${unit}:${String(key ?? 'default')}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{
|
|
42
|
+
* limits?: Array<{unit:string, limitTokens?:number, limitCost?:number, onExhaust?:string, degradeTo?:string}>,
|
|
43
|
+
* degradeModels?: string[], // degrade 允许的廉价模型白名单(不在此列表 ⇒ 一律 hard-stop)
|
|
44
|
+
* now?: () => number,
|
|
45
|
+
* }} [options]
|
|
46
|
+
*/
|
|
47
|
+
export function createBudget(options = {}) {
|
|
48
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
49
|
+
const source = Array.isArray(options.limits) && options.limits.length > 0 ? options.limits : DEFAULT_BUDGET_LIMITS
|
|
50
|
+
const degradeModels = Object.freeze(
|
|
51
|
+
(Array.isArray(options.degradeModels) ? options.degradeModels : [])
|
|
52
|
+
.map((item) => String(item ?? '').trim())
|
|
53
|
+
.filter(Boolean),
|
|
54
|
+
)
|
|
55
|
+
const limits = []
|
|
56
|
+
const invalid = []
|
|
57
|
+
for (const item of source) {
|
|
58
|
+
const unit = String(item?.unit ?? '')
|
|
59
|
+
const limitTokens = item?.limitTokens === undefined ? null : positiveLimit(item.limitTokens)
|
|
60
|
+
const limitCost = item?.limitCost === undefined ? null : positiveLimit(item.limitCost)
|
|
61
|
+
if (!UNITS.includes(unit) || (limitTokens === null && limitCost === null)) {
|
|
62
|
+
// 既无 token 上限也无成本上限的规则没有意义:配置错误必须显式暴露(fail-closed)。
|
|
63
|
+
invalid.push({ unit, limitTokens: item?.limitTokens, limitCost: item?.limitCost })
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (limits.some((rule) => rule.unit === unit)) {
|
|
67
|
+
// 同一 unit 只能有一条规则(先注册先得),否则判定口径不确定。
|
|
68
|
+
invalid.push({ unit, reason: 'duplicate-unit' })
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const onExhaust = EXHAUST_ACTIONS.includes(String(item?.onExhaust)) ? String(item.onExhaust) : 'hard-stop'
|
|
72
|
+
const degradeTo = item?.degradeTo === undefined ? null : String(item.degradeTo)
|
|
73
|
+
// degrade 档必须在白名单内,否则等于开了个后门:降级为 hard-stop。
|
|
74
|
+
const effective = onExhaust === 'degrade' && degradeTo !== null && degradeModels.includes(degradeTo)
|
|
75
|
+
? 'degrade'
|
|
76
|
+
: 'hard-stop'
|
|
77
|
+
if (onExhaust === 'degrade' && effective !== 'degrade') {
|
|
78
|
+
invalid.push({ unit, degradeTo, reason: 'degrade-model-not-allowlisted' })
|
|
79
|
+
}
|
|
80
|
+
limits.push(Object.freeze({
|
|
81
|
+
unit,
|
|
82
|
+
limitTokens,
|
|
83
|
+
limitCost,
|
|
84
|
+
onExhaust: effective,
|
|
85
|
+
degradeTo: effective === 'degrade' ? degradeTo : null,
|
|
86
|
+
}))
|
|
87
|
+
}
|
|
88
|
+
const limitOf = (unit) => limits.find((item) => item.unit === unit) ?? null
|
|
89
|
+
const consumed = new Map()
|
|
90
|
+
const used = (unit, key) => consumed.get(keyOf(unit, key)) ?? { tokens: 0, cost: 0 }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ① beforeModelCall 的唯一检查点。
|
|
94
|
+
* @param {{unit: string, key?: string, tokens?: number, cost?: number}} input
|
|
95
|
+
*/
|
|
96
|
+
function authorize(input = {}) {
|
|
97
|
+
const unit = String(input.unit)
|
|
98
|
+
const key = String(input.key ?? 'default')
|
|
99
|
+
const requested = Number(input.tokens ?? 0)
|
|
100
|
+
const requestedCost = Number(input.cost ?? 0)
|
|
101
|
+
if (!Number.isFinite(requested) || requested < 0) {
|
|
102
|
+
return masterError('tokens-invalid', 'tokens must be a non-negative number')
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isFinite(requestedCost) || requestedCost < 0) {
|
|
105
|
+
return masterError('cost-invalid', 'cost must be a non-negative number')
|
|
106
|
+
}
|
|
107
|
+
const limit = limitOf(unit)
|
|
108
|
+
if (!limit) {
|
|
109
|
+
return { ok: true, unlimited: true, unit, key, requested, remaining: null, costTracked: false, at: now() }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const current = used(unit, key)
|
|
113
|
+
const remainingTokens = limit.limitTokens === null ? null : Math.max(0, limit.limitTokens - current.tokens)
|
|
114
|
+
const remainingCost = limit.limitCost === null ? null : Math.max(0, limit.limitCost - current.cost)
|
|
115
|
+
const tokenExhausted = limit.limitTokens !== null && requested > remainingTokens
|
|
116
|
+
const costExhausted = limit.limitCost !== null && requestedCost > remainingCost
|
|
117
|
+
const costTracked = limit.limitCost === null ? false : Number(input.cost) > 0
|
|
118
|
+
|
|
119
|
+
if (tokenExhausted || costExhausted) {
|
|
120
|
+
const reason = tokenExhausted ? 'tokens' : 'cost'
|
|
121
|
+
// degrade 档:只降级到白名单里的廉价模型;否则一律 fail-closed。
|
|
122
|
+
if (limit.onExhaust === 'degrade' && limit.degradeTo && degradeModels.includes(limit.degradeTo)) {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
degraded: true,
|
|
126
|
+
action: 'degrade',
|
|
127
|
+
unit,
|
|
128
|
+
key,
|
|
129
|
+
model: limit.degradeTo,
|
|
130
|
+
reason,
|
|
131
|
+
limitTokens: limit.limitTokens,
|
|
132
|
+
limitCost: limit.limitCost,
|
|
133
|
+
consumedTokens: current.tokens,
|
|
134
|
+
consumedCost: current.cost,
|
|
135
|
+
remainingTokens,
|
|
136
|
+
remainingCost,
|
|
137
|
+
requested,
|
|
138
|
+
requestedCost,
|
|
139
|
+
costTracked,
|
|
140
|
+
onExhaust: 'degrade',
|
|
141
|
+
at: now(),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// fail-closed:拒绝且**不扣额度**,绝不「先跑着看」。
|
|
145
|
+
return masterError('budget-exhausted', `预算不足:${unit}/${key} 剩余 ${remainingTokens ?? '∞'} token`, {
|
|
146
|
+
unit,
|
|
147
|
+
key,
|
|
148
|
+
reason,
|
|
149
|
+
limitTokens: limit.limitTokens,
|
|
150
|
+
limitCost: limit.limitCost,
|
|
151
|
+
consumedTokens: current.tokens,
|
|
152
|
+
consumedCost: current.cost,
|
|
153
|
+
remainingTokens,
|
|
154
|
+
remainingCost,
|
|
155
|
+
requested,
|
|
156
|
+
requestedCost,
|
|
157
|
+
costTracked,
|
|
158
|
+
onExhaust: limit.onExhaust,
|
|
159
|
+
/** 剩余信息同时平铺在顶层,便于调用方不需解 detail。 */
|
|
160
|
+
remaining: { tokens: remainingTokens, cost: remainingCost },
|
|
161
|
+
at: now(),
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const nextTokens = current.tokens + Math.floor(requested)
|
|
166
|
+
const nextCost = current.cost + requestedCost
|
|
167
|
+
consumed.set(keyOf(unit, key), { tokens: nextTokens, cost: nextCost })
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
unit,
|
|
171
|
+
key,
|
|
172
|
+
requested,
|
|
173
|
+
requestedCost,
|
|
174
|
+
consumedTokens: nextTokens,
|
|
175
|
+
consumedCost: nextCost,
|
|
176
|
+
limitTokens: limit.limitTokens,
|
|
177
|
+
limitCost: limit.limitCost,
|
|
178
|
+
remainingTokens: limit.limitTokens === null ? null : limit.limitTokens - nextTokens,
|
|
179
|
+
remainingCost: limit.limitCost === null ? null : limit.limitCost - nextCost,
|
|
180
|
+
costTracked,
|
|
181
|
+
at: now(),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function refund({ unit, key = 'default', tokens = 0, cost = 0 } = {}) {
|
|
186
|
+
const n = Number(tokens)
|
|
187
|
+
const c = Number(cost)
|
|
188
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isFinite(c) || c < 0 || (n === 0 && c === 0)) {
|
|
189
|
+
return masterError('refund-invalid', 'refund requires positive tokens and/or cost')
|
|
190
|
+
}
|
|
191
|
+
const k = keyOf(String(unit), key)
|
|
192
|
+
const current = consumed.get(k) ?? { tokens: 0, cost: 0 }
|
|
193
|
+
const next = { tokens: Math.max(0, current.tokens - Math.floor(n)), cost: Math.max(0, current.cost - c) }
|
|
194
|
+
consumed.set(k, next)
|
|
195
|
+
return { ok: true, unit: String(unit), key: String(key), consumedTokens: next.tokens, consumedCost: next.cost, at: now() }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function state() {
|
|
199
|
+
const rows = []
|
|
200
|
+
for (const limit of limits) {
|
|
201
|
+
for (const [k, value] of consumed.entries()) {
|
|
202
|
+
const prefix = `${limit.unit}:`
|
|
203
|
+
if (!k.startsWith(prefix)) continue
|
|
204
|
+
rows.push(Object.freeze({
|
|
205
|
+
unit: limit.unit,
|
|
206
|
+
key: k.slice(prefix.length),
|
|
207
|
+
consumedTokens: value.tokens,
|
|
208
|
+
consumedCost: value.cost,
|
|
209
|
+
limitTokens: limit.limitTokens,
|
|
210
|
+
limitCost: limit.limitCost,
|
|
211
|
+
remainingTokens: limit.limitTokens === null ? null : Math.max(0, limit.limitTokens - value.tokens),
|
|
212
|
+
remainingCost: limit.limitCost === null ? null : Math.max(0, limit.limitCost - value.cost),
|
|
213
|
+
onExhaust: limit.onExhaust,
|
|
214
|
+
}))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return Object.freeze({ ok: true, limits, invalid, degradeModels: degradeModels.slice(), rows, at: now() })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function reset() {
|
|
221
|
+
consumed.clear()
|
|
222
|
+
return { ok: true }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return Object.freeze({ authorize, refund, state, reset, limits: () => limits.slice(), degradeModels: () => degradeModels.slice() })
|
|
226
|
+
}
|
package/src/cost/bus.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 3.3.0 系统层 · bus(事件总线与回流出口,设计 §5.6)。
|
|
7
|
+
*
|
|
8
|
+
* 对外只暴露三种内部事件(冒号命名,与 Router 事件通道一致):
|
|
9
|
+
* * `cost:event` —— 账本/归因/预算等一般成本事件(payload.kind 区分);
|
|
10
|
+
* * `cost:degraded` —— 模块降级(不伪装成节省);
|
|
11
|
+
* * `cost:substitution` —— 替换证书(与审计链同源)。
|
|
12
|
+
*
|
|
13
|
+
* 可选对外出口(3.3.0 不阻塞主线):
|
|
14
|
+
* * `web` —— Worker→Router 既有事件通道(Router 落库后供状态页/分身信息栏读取);
|
|
15
|
+
* * `fang` —— 瓮城「产物回流」;未就绪时状态里必须显式 `available: false` + 原因,
|
|
16
|
+
* **不得**静默丢弃或假装成功。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const COST_EVENT_TYPES = Object.freeze(['cost:event', 'cost:degraded', 'cost:substitution'])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{maxEvents?: number, now?: () => number, relays?: Array<{name:string, publish?:Function, available?:boolean, reason?:string}>}} [options]
|
|
23
|
+
*/
|
|
24
|
+
export function createCostBus(options = {}) {
|
|
25
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
26
|
+
const maxEvents = Math.max(1, Number(options.maxEvents ?? 200) || 200)
|
|
27
|
+
const events = []
|
|
28
|
+
const subscribers = new Set()
|
|
29
|
+
/** 外部出口(web / fang);available=false 的出口只记录状态,不投递。 */
|
|
30
|
+
const relays = (Array.isArray(options.relays) ? options.relays : [])
|
|
31
|
+
.filter((relay) => relay && typeof relay === 'object' && relay.name)
|
|
32
|
+
.map((relay) => ({
|
|
33
|
+
name: String(relay.name),
|
|
34
|
+
publish: typeof relay.publish === 'function' ? relay.publish : null,
|
|
35
|
+
available: relay.available === true && typeof relay.publish === 'function',
|
|
36
|
+
reason: String(relay.reason ?? ''),
|
|
37
|
+
delivered: 0,
|
|
38
|
+
failed: 0,
|
|
39
|
+
}))
|
|
40
|
+
let dropped = 0
|
|
41
|
+
|
|
42
|
+
function publish(type, payload = {}) {
|
|
43
|
+
const event = Object.freeze({ type: String(type ?? 'unknown'), at: now(), payload: Object.freeze({ ...payload }) })
|
|
44
|
+
events.push(event)
|
|
45
|
+
if (events.length > maxEvents) {
|
|
46
|
+
events.shift()
|
|
47
|
+
dropped += 1
|
|
48
|
+
}
|
|
49
|
+
for (const listener of [...subscribers]) {
|
|
50
|
+
try { listener(event) } catch { /* 订阅者异常不影响成本链路 */ }
|
|
51
|
+
}
|
|
52
|
+
for (const relay of relays) {
|
|
53
|
+
if (!relay.available || !relay.publish) continue
|
|
54
|
+
try {
|
|
55
|
+
relay.publish(event)
|
|
56
|
+
relay.delivered += 1
|
|
57
|
+
} catch {
|
|
58
|
+
relay.failed += 1
|
|
59
|
+
// 出口失败不得影响主流程,但必须留痕(failed 计数),不静默吞掉。
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return event
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subscribe(listener) {
|
|
66
|
+
if (typeof listener !== 'function') return () => {}
|
|
67
|
+
subscribers.add(listener)
|
|
68
|
+
return () => { subscribers.delete(listener) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function snapshot(limit = 50) {
|
|
72
|
+
const n = Math.max(1, Number(limit) || 50)
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
ok: true,
|
|
75
|
+
count: events.length,
|
|
76
|
+
dropped,
|
|
77
|
+
subscribers: subscribers.size,
|
|
78
|
+
events: events.slice(-n),
|
|
79
|
+
/** §5.6 出口状态:不可用必须显式说明原因,不假装成功。 */
|
|
80
|
+
relays: relays.map((relay) => Object.freeze({
|
|
81
|
+
name: relay.name,
|
|
82
|
+
available: relay.available,
|
|
83
|
+
reason: relay.reason,
|
|
84
|
+
delivered: relay.delivered,
|
|
85
|
+
failed: relay.failed,
|
|
86
|
+
})),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reset() {
|
|
91
|
+
events.length = 0
|
|
92
|
+
dropped = 0
|
|
93
|
+
return { ok: true }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.freeze({ publish, subscribe, snapshot, reset })
|
|
97
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 3.3.0 · M4 交付物:**证书链核对报告**(真实替换 → 证书 → 审计链 → 账本)。
|
|
7
|
+
*
|
|
8
|
+
* 一张证书要说清楚三件事,缺一件就不算"可回放":
|
|
9
|
+
*
|
|
10
|
+
* 1. **派发证据**:`viaNativeDispatcher === true` 且 `dispatched === true`(真的走了原生派发器);
|
|
11
|
+
* 2. **审计落链**:能在审计链里按 `taskId + step + edgeId` 找到对应记录,且 `verify()` 不断链;
|
|
12
|
+
* 3. **账本对账**:同一 `taskId + step` 的替换账本事件金额与证书金额一致。
|
|
13
|
+
*
|
|
14
|
+
* 任何一项不成立 ⇒ 该证书 `ok:false` 并给出 `issue`,报表整体 `ok:false`。
|
|
15
|
+
* 失败替换(`dispatched:false`)单列:它们是**自动回退的证据**,不是节省。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const num = (value) => {
|
|
19
|
+
const n = Number(value)
|
|
20
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* certificates?: Array<object>,
|
|
26
|
+
* entries?: Array<object>,
|
|
27
|
+
* audit?: {ok?: boolean, count?: number, records?: Array<object>},
|
|
28
|
+
* }} input
|
|
29
|
+
*/
|
|
30
|
+
export function buildCertChainReport(input = {}) {
|
|
31
|
+
const certificates = Array.isArray(input.certificates) ? input.certificates : []
|
|
32
|
+
const entries = Array.isArray(input.entries) ? input.entries : []
|
|
33
|
+
const auditOk = input?.audit?.ok !== false
|
|
34
|
+
const auditRecords = Array.isArray(input?.audit?.records) ? input.audit.records : []
|
|
35
|
+
|
|
36
|
+
// 替换账本事件:按 taskId+step 索引(同一步可能有多条,取合计)
|
|
37
|
+
const ledgerByKey = new Map()
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry?.meta?.kind !== 'substitution') continue
|
|
40
|
+
const key = `${String(entry.taskId)}|${Number(entry.step)}`
|
|
41
|
+
ledgerByKey.set(key, (ledgerByKey.get(key) ?? 0) + num(entry.savedTokens))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rows = certificates.map((cert) => {
|
|
45
|
+
const taskId = String(cert?.taskId ?? '')
|
|
46
|
+
const step = Number(cert?.step ?? -1)
|
|
47
|
+
const key = `${taskId}|${step}`
|
|
48
|
+
// 审计链只接受已派发的证书;因此 dispatched 缺省视为 true,显式 false 才是失败替换。
|
|
49
|
+
const dispatched = cert?.dispatched !== false
|
|
50
|
+
const native = cert?.viaNativeDispatcher === true
|
|
51
|
+
const ledgerSaved = ledgerByKey.get(key) ?? null
|
|
52
|
+
const certSaved = num(cert?.savedTokens)
|
|
53
|
+
const audited = auditRecords.some((record) => String(record?.payload?.edgeId ?? '') === String(cert?.edgeId ?? '')
|
|
54
|
+
&& String(record?.payload?.taskId ?? '') === taskId
|
|
55
|
+
&& Number(record?.payload?.step ?? -1) === step)
|
|
56
|
+
|
|
57
|
+
const issues = []
|
|
58
|
+
if (!native) issues.push('not-native-dispatcher')
|
|
59
|
+
if (dispatched && ledgerSaved === null) issues.push('missing-ledger-entry')
|
|
60
|
+
if (dispatched && ledgerSaved !== null && ledgerSaved !== certSaved) issues.push('ledger-mismatch')
|
|
61
|
+
if (dispatched && !audited && auditRecords.length > 0) issues.push('missing-audit-record')
|
|
62
|
+
if (dispatched && !auditOk) issues.push('audit-chain-broken')
|
|
63
|
+
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
edgeId: String(cert?.edgeId ?? ''),
|
|
66
|
+
toolName: String(cert?.toolName ?? ''),
|
|
67
|
+
replacedCallbackId: String(cert?.replacedCallbackId ?? ''),
|
|
68
|
+
taskId,
|
|
69
|
+
step,
|
|
70
|
+
dispatched,
|
|
71
|
+
viaNativeDispatcher: native,
|
|
72
|
+
certificateSavedTokens: certSaved,
|
|
73
|
+
ledgerSavedTokens: ledgerSaved,
|
|
74
|
+
audited,
|
|
75
|
+
issue: issues.length > 0 ? issues.join(',') : null,
|
|
76
|
+
/** 失败替换是「自动回退」的证据,不是缺陷也不是节省。 */
|
|
77
|
+
fallback: !dispatched,
|
|
78
|
+
ok: issues.length === 0,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const dispatchedRows = rows.filter((row) => row.dispatched)
|
|
83
|
+
const fallbackRows = rows.filter((row) => row.fallback)
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
ok: rows.every((row) => row.ok),
|
|
86
|
+
certificates: rows.length,
|
|
87
|
+
dispatched: dispatchedRows.length,
|
|
88
|
+
fallbacks: fallbackRows.length,
|
|
89
|
+
/** 证书声明的节省总额(只算派发成功的)。 */
|
|
90
|
+
savedTokens: dispatchedRows.reduce((sum, row) => sum + num(row.certificateSavedTokens), 0),
|
|
91
|
+
audit: Object.freeze({ ok: auditOk, records: Number(input?.audit?.count ?? auditRecords.length) }),
|
|
92
|
+
/** 自动回退证据:每条失败替换的原因。 */
|
|
93
|
+
fallbackReasons: Object.freeze(fallbackRows.map((row) => Object.freeze({ edgeId: row.edgeId, reason: String(row.issue ?? 'not-dispatched') }))),
|
|
94
|
+
rows: Object.freeze(rows),
|
|
95
|
+
discipline: Object.freeze({
|
|
96
|
+
/** 失败替换不计节省、不算替换成功。 */
|
|
97
|
+
fallbacksNeverCountAsSavings: fallbackRows.every((row) => num(row.certificateSavedTokens) === 0 || row.dispatched === false),
|
|
98
|
+
everyDispatchHasLedgerEntry: dispatchedRows.every((row) => row.ledgerSavedTokens !== null),
|
|
99
|
+
nativeDispatcherOnly: rows.every((row) => row.viaNativeDispatcher === true),
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
}
|