@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

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.
Files changed (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,308 @@
1
+ import type { NormalizedUsage, ReasoningBilling, UsageApplicability, UsdDecimal } from '../contracts.js'
2
+ import { UsdDecimal as usd } from '../contracts.js'
3
+ import { FusionError, USAGE_LEDGER_REQUIRED } from '../errors.js'
4
+ import {
5
+ known,
6
+ na,
7
+ splitInput,
8
+ unknown,
9
+ type CanonicalBill,
10
+ type Count,
11
+ type UsageObservation,
12
+ type UsageRequestKey,
13
+ } from './ledger.js'
14
+
15
+ export interface RawUsage {
16
+ inputTokens?: number | null
17
+ outputTokens?: number | null
18
+ cacheReadTokens?: number | null
19
+ cacheWriteTokens?: number | null
20
+ cacheWriteByTtl?: Readonly<Record<string, number | null>>
21
+ reasoningTokens?: number | null
22
+ /** When true, inputTokens already includes cacheReadTokens. */
23
+ inputIncludesCacheRead?: boolean
24
+ /** When true, outputTokens already includes reasoningTokens. */
25
+ outputIncludesReasoning?: boolean
26
+ }
27
+
28
+ export interface PriceCard {
29
+ digest: string
30
+ uncachedInputPerMillion?: string | null
31
+ cacheReadPerMillion?: string | null
32
+ cacheWritePerMillion?: Readonly<Record<string, string | null>>
33
+ outputPerMillion?: string | null
34
+ reasoningPerMillion?: string | null
35
+ }
36
+
37
+ export type UsageObservationKind = 'partial' | 'final'
38
+
39
+ export interface UsageObservationMeta {
40
+ requestId?: string
41
+ source?: UsageObservationKind
42
+ sequence?: number
43
+ cumulative?: boolean
44
+ observationId?: string
45
+ }
46
+
47
+ export type UsagePriceStatus = 'complete' | 'incomplete' | 'unknown'
48
+
49
+ export interface UsagePrice {
50
+ status: UsagePriceStatus
51
+ /** Complete total only. Never a partial stand-in for ranking. */
52
+ totalUsd: UsdDecimal | null
53
+ /** Lower bound of priced known buckets. Not a substitute total. */
54
+ observedUsd: UsdDecimal | null
55
+ reasons: readonly string[]
56
+ }
57
+
58
+ function finite(value: number | null | undefined): number | null {
59
+ if (value === null || value === undefined) return null
60
+ if (!Number.isFinite(value) || value < 0) throw new TypeError(`usage must be a finite nonnegative number, got ${value}`)
61
+ return value
62
+ }
63
+
64
+ function applicability(value: number | null | undefined, present: boolean): UsageApplicability {
65
+ if (!present) return 'not_applicable'
66
+ if (value === null || value === undefined) return 'unknown'
67
+ return 'known'
68
+ }
69
+
70
+ function toCount(value: number | null, status: UsageApplicability): Count {
71
+ if (status === 'not_applicable') return na()
72
+ if (status === 'unknown' || value === null) return unknown()
73
+ return known(value)
74
+ }
75
+
76
+ function fromCount(value: Count): number | null {
77
+ return value.state === 'known' ? value.tokens : null
78
+ }
79
+
80
+ function reasoningFromRaw(raw: RawUsage): ReasoningBilling {
81
+ const present = Object.prototype.hasOwnProperty.call(raw, 'reasoningTokens')
82
+ const tokens = finite(raw.reasoningTokens)
83
+ if (raw.outputIncludesReasoning === true) return { kind: 'included', tokens }
84
+ if (raw.outputIncludesReasoning === false) return { kind: 'separate', tokens }
85
+ if (!present) return { kind: 'not_applicable', tokens: null }
86
+ return { kind: 'unknown', tokens }
87
+ }
88
+
89
+ export function normalizeUsage(raw: RawUsage): NormalizedUsage {
90
+ const cacheReadPresent = Object.prototype.hasOwnProperty.call(raw, 'cacheReadTokens')
91
+ const cacheRead = finite(raw.cacheReadTokens)
92
+ const input = finite(raw.inputTokens)
93
+ const includesCache = raw.inputIncludesCacheRead ?? null
94
+ const cacheReadStatus = applicability(raw.cacheReadTokens, cacheReadPresent)
95
+ const split = splitInput(toCount(input, input === null ? 'unknown' : 'known'), toCount(cacheRead, cacheReadStatus), includesCache)
96
+ const cacheWritePresent = Object.prototype.hasOwnProperty.call(raw, 'cacheWriteTokens')
97
+ || Object.prototype.hasOwnProperty.call(raw, 'cacheWriteByTtl')
98
+ let cacheWrite: Record<string, number | null> = {}
99
+ let cacheWriteStatus: Record<string, UsageApplicability> = {}
100
+ if (raw.cacheWriteByTtl) {
101
+ cacheWrite = Object.fromEntries(Object.entries(raw.cacheWriteByTtl).map(([ttl, tokens]) => [ttl, finite(tokens)]))
102
+ cacheWriteStatus = Object.fromEntries(
103
+ Object.entries(raw.cacheWriteByTtl).map(([ttl, tokens]) => [ttl, applicability(tokens, true)]),
104
+ )
105
+ } else if (cacheWritePresent) {
106
+ cacheWrite = { default: finite(raw.cacheWriteTokens) }
107
+ cacheWriteStatus = { default: applicability(raw.cacheWriteTokens, true) }
108
+ }
109
+ const output = finite(raw.outputTokens)
110
+ const reasoningBilling = reasoningFromRaw(raw)
111
+ return {
112
+ uncachedInput: fromCount(split),
113
+ cacheRead,
114
+ cacheReadStatus,
115
+ cacheWrite,
116
+ cacheWriteStatus,
117
+ output,
118
+ reasoningOutputSubset: reasoningBilling.kind === 'included' ? reasoningBilling.tokens : null,
119
+ reasoningSeparate: reasoningBilling.kind === 'separate' ? reasoningBilling.tokens : null,
120
+ reasoningUnspecified: reasoningBilling.kind === 'unknown' ? reasoningBilling.tokens : null,
121
+ reasoningBilling,
122
+ inputIncludesCacheRead: includesCache,
123
+ }
124
+ }
125
+
126
+ function cacheWritesFromUsage(usage: NormalizedUsage): CanonicalBill['cacheWrite'] {
127
+ const keys = Object.keys(usage.cacheWrite)
128
+ if (!keys.length) return { kind: 'not_applicable' }
129
+ if (keys.some(key => usage.cacheWriteStatus[key] === 'unknown' || usage.cacheWrite[key] === null)) {
130
+ if (keys.length === 1 && keys[0] === 'default') {
131
+ return usage.cacheWriteStatus.default === 'unknown' || usage.cacheWrite.default === null
132
+ ? { kind: 'unknown' }
133
+ : { kind: 'aggregate', rateKey: 'default', tokens: toCount(usage.cacheWrite.default ?? null, usage.cacheWriteStatus.default ?? 'known') }
134
+ }
135
+ }
136
+ if (keys.length === 1 && keys[0] === 'default') {
137
+ return {
138
+ kind: 'aggregate',
139
+ rateKey: 'default',
140
+ tokens: toCount(usage.cacheWrite.default ?? null, usage.cacheWriteStatus.default ?? (usage.cacheWrite.default === null ? 'unknown' : 'known')),
141
+ }
142
+ }
143
+ return {
144
+ kind: 'details',
145
+ buckets: Object.fromEntries(keys.map(key => [key, {
146
+ rateKey: key,
147
+ tokens: toCount(usage.cacheWrite[key] ?? null, usage.cacheWriteStatus[key] ?? (usage.cacheWrite[key] === null ? 'unknown' : 'known')),
148
+ }])),
149
+ }
150
+ }
151
+
152
+ function billFromUsage(usage: NormalizedUsage): CanonicalBill {
153
+ const billing = usage.reasoningBilling ?? (
154
+ usage.reasoningOutputSubset !== null
155
+ ? { kind: 'included' as const, tokens: usage.reasoningOutputSubset }
156
+ : usage.reasoningSeparate !== null
157
+ ? { kind: 'separate' as const, tokens: usage.reasoningSeparate }
158
+ : usage.reasoningUnspecified !== null
159
+ ? { kind: 'unknown' as const, tokens: usage.reasoningUnspecified }
160
+ : { kind: 'not_applicable' as const, tokens: null }
161
+ )
162
+ return {
163
+ uncachedInput: toCount(usage.uncachedInput, usage.uncachedInput === null ? 'unknown' : 'known'),
164
+ cacheRead: toCount(usage.cacheRead, usage.cacheReadStatus),
165
+ output: toCount(usage.output, usage.output === null ? 'unknown' : 'known'),
166
+ cacheWrite: cacheWritesFromUsage(usage),
167
+ reasoning: {
168
+ kind: billing.kind,
169
+ tokens: billing.kind === 'not_applicable' ? na() : toCount(billing.tokens, billing.tokens === null ? 'unknown' : 'known'),
170
+ },
171
+ }
172
+ }
173
+
174
+ function observationMode(meta?: UsageObservationMeta): UsageObservation['mode'] {
175
+ if (meta?.source === 'final') return 'final'
176
+ if (meta?.cumulative === false) return 'delta'
177
+ return 'snapshot'
178
+ }
179
+
180
+ function asObservation(usage: NormalizedUsage, meta: UsageObservationMeta | undefined, key: UsageRequestKey, side: 'first' | 'second'): UsageObservation {
181
+ const bill = billFromUsage(usage)
182
+ const sequence = meta?.sequence ?? (side === 'first' ? 0 : 1)
183
+ return {
184
+ id: meta?.observationId ?? `anon:${side}:${sequence}:${JSON.stringify(bill)}`,
185
+ key,
186
+ sequence,
187
+ mode: observationMode(meta),
188
+ bill,
189
+ }
190
+ }
191
+
192
+ export function observationFromNormalized(
193
+ usage: NormalizedUsage,
194
+ key: UsageRequestKey,
195
+ meta: UsageObservationMeta & { id?: string },
196
+ ): UsageObservation {
197
+ return asObservation(usage, { ...meta, observationId: meta.id ?? meta.observationId }, key, 'first')
198
+ }
199
+
200
+ /**
201
+ * Retired. Persist UsageLedgerV2 and quoteLedger(). A NormalizedUsage projection
202
+ * is not enough history to replay a request bill.
203
+ */
204
+ export function mergeUsageObservations(
205
+ ..._args: unknown[]
206
+ ): never {
207
+ throw new FusionError(
208
+ USAGE_LEDGER_REQUIRED,
209
+ 'persist UsageLedgerV2 with ingestDurable/restoreLedger; mergeUsageObservations cannot recover request history',
210
+ )
211
+ }
212
+
213
+ function millionths(tokens: number, rate: string): bigint {
214
+ const [whole, frac = ''] = rate.split('.')
215
+ const digits = `${whole}${frac.padEnd(6, '0').slice(0, 6)}`
216
+ const perMillion = BigInt(digits)
217
+ return (BigInt(tokens) * perMillion) / 1_000_000n
218
+ }
219
+
220
+ function decimalFromMillionths(value: bigint): UsdDecimal {
221
+ const negative = value < 0n
222
+ const abs = negative ? -value : value
223
+ const whole = abs / 1_000_000n
224
+ const frac = (abs % 1_000_000n).toString().padStart(6, '0').replace(/0+$/, '')
225
+ const text = frac ? `${whole.toString()}.${frac}` : whole.toString()
226
+ return usd(negative ? `-${text}` : text)
227
+ }
228
+
229
+ type BucketKind = 'required' | 'optional'
230
+
231
+ function priceBucket(
232
+ tokens: number | null,
233
+ rate: string | null | undefined,
234
+ label: string,
235
+ kind: BucketKind,
236
+ parts: bigint[],
237
+ reasons: string[],
238
+ ): void {
239
+ if (tokens === null) {
240
+ if (kind === 'required') reasons.push(`${label} tokens unknown`)
241
+ return
242
+ }
243
+ if (tokens === 0) return
244
+ if (rate === null || rate === undefined) {
245
+ reasons.push(`${label} rate missing`)
246
+ return
247
+ }
248
+ parts.push(millionths(tokens, rate))
249
+ }
250
+
251
+ export function priceUsage(usage: NormalizedUsage, card: PriceCard | undefined): UsagePrice {
252
+ if (!card) {
253
+ return { status: 'unknown', totalUsd: null, observedUsd: null, reasons: ['no price card'] }
254
+ }
255
+
256
+ const parts: bigint[] = []
257
+ const reasons: string[] = []
258
+ const unreliableBound = usage.inputIncludesCacheRead === true && usage.cacheReadStatus !== 'known'
259
+ if (unreliableBound) {
260
+ reasons.push('input includes cache but the cache split is unknown; observed is not a reliable lower bound')
261
+ }
262
+ priceBucket(usage.uncachedInput, card.uncachedInputPerMillion, 'uncachedInput', 'required', parts, reasons)
263
+ if (usage.cacheReadStatus === 'unknown') {
264
+ reasons.push('cacheRead tokens unknown')
265
+ } else if (usage.cacheReadStatus === 'known') {
266
+ priceBucket(usage.cacheRead, card.cacheReadPerMillion, 'cacheRead', 'optional', parts, reasons)
267
+ }
268
+ priceBucket(usage.output, card.outputPerMillion, 'output', 'required', parts, reasons)
269
+ for (const [ttl, tokens] of Object.entries(usage.cacheWrite)) {
270
+ const status = usage.cacheWriteStatus[ttl] ?? (tokens === null ? 'unknown' : 'known')
271
+ if (status === 'unknown') {
272
+ reasons.push(`cacheWrite.${ttl} tokens unknown`)
273
+ continue
274
+ }
275
+ if (status === 'not_applicable') continue
276
+ priceBucket(tokens, card.cacheWritePerMillion?.[ttl] ?? card.cacheWritePerMillion?.default, `cacheWrite.${ttl}`, 'optional', parts, reasons)
277
+ }
278
+ if (usage.reasoningBilling?.kind === 'separate' || usage.reasoningSeparate !== null) {
279
+ const rate = card.reasoningPerMillion ?? card.outputPerMillion
280
+ priceBucket(usage.reasoningBilling?.tokens ?? usage.reasoningSeparate, rate, 'reasoningSeparate', 'required', parts, reasons)
281
+ }
282
+ if (usage.reasoningBilling?.kind === 'unknown' || (usage.reasoningUnspecified !== null && usage.reasoningUnspecified !== 0)) {
283
+ reasons.push('reasoning contract unspecified; cannot add or omit as a complete total')
284
+ }
285
+
286
+ const observed = !unreliableBound && parts.length
287
+ ? decimalFromMillionths(parts.reduce((sum, part) => sum + part, 0n))
288
+ : (!unreliableBound && !parts.length ? null : null)
289
+ const primaryMissing = usage.uncachedInput === null && usage.output === null && usage.cacheReadStatus !== 'known'
290
+ && Object.values(usage.cacheWriteStatus).every(status => status !== 'known')
291
+ && usage.reasoningSeparate === null
292
+ && usage.reasoningOutputSubset === null
293
+ && (usage.reasoningUnspecified === null)
294
+
295
+ if (reasons.length && primaryMissing) {
296
+ return { status: 'unknown', totalUsd: null, observedUsd: observed, reasons }
297
+ }
298
+ if (reasons.length) {
299
+ return { status: 'incomplete', totalUsd: null, observedUsd: observed, reasons }
300
+ }
301
+ const total = observed ?? usd('0')
302
+ return { status: 'complete', totalUsd: total, observedUsd: total, reasons: [] }
303
+ }
304
+
305
+ export function apiEquivalentUsd(usage: NormalizedUsage, card: PriceCard | undefined): UsdDecimal | null {
306
+ const priced = priceUsage(usage, card)
307
+ return priced.status === 'complete' ? priced.totalUsd : null
308
+ }
@@ -0,0 +1,95 @@
1
+ import { digestOf } from '../digest.js'
2
+ import { FusionError, USAGE_LEDGER_REQUIRED } from '../errors.js'
3
+ import {
4
+ projectLedger,
5
+ restoreLedger,
6
+ type Count,
7
+ type UsageLedgerV2,
8
+ } from './ledger.js'
9
+
10
+ export interface PriceRates {
11
+ input?: string
12
+ cachedInput?: string
13
+ output?: string
14
+ /** Explicit separate-reasoning rate; no assumed relationship to output price. */
15
+ reasoning?: string
16
+ cacheWrite?: Readonly<Record<string, string>>
17
+ }
18
+
19
+ export interface RequestQuote {
20
+ authority: 'none' | 'provisional' | 'final'
21
+ status: 'unknown' | 'provisional' | 'incomplete' | 'complete'
22
+ totalUsd: string | null
23
+ estimateUsd: string | null
24
+ priceCardDigest: string
25
+ missing: string[]
26
+ }
27
+
28
+ function requireThat(value: unknown, code: string): asserts value {
29
+ if (!value) throw new FusionError(USAGE_LEDGER_REQUIRED, code)
30
+ }
31
+
32
+ /** 18 decimal places in USD: a rate with <=12 decimals per 1e6 tokens is exact. */
33
+ function rateUnits(rate: string): bigint {
34
+ requireThat(/^\d+(?:\.\d{1,12})?$/.test(rate), 'INVALID_PRICE_RATE')
35
+ const [whole, frac = ''] = rate.split('.')
36
+ return BigInt(whole! + frac.padEnd(12, '0'))
37
+ }
38
+
39
+ function decimal(units: bigint): string {
40
+ const div = 10n ** 18n
41
+ const fraction = (units % div).toString().padStart(18, '0').replace(/0+$/, '')
42
+ return `${units / div}${fraction ? `.${fraction}` : ''}`
43
+ }
44
+
45
+ export function quoteLedger(ledger: UsageLedgerV2, rates: PriceRates, priceCardDigest = digestOf(rates)): RequestQuote {
46
+ const projection = projectLedger(restoreLedger(ledger))
47
+ const bill = projection.bill
48
+ const missing: string[] = []
49
+ let units = 0n
50
+ function add(count: Count, rate: string | undefined, name: string) {
51
+ if (count.state === 'not_applicable') return
52
+ if (count.state === 'unknown') {
53
+ missing.push(`${name}:unknown`)
54
+ return
55
+ }
56
+ requireThat(Number.isSafeInteger(count.tokens) && count.tokens >= 0, 'INVALID_COUNT')
57
+ if (count.tokens === 0) return
58
+ if (rate === undefined) {
59
+ missing.push(`${name}:rate-missing`)
60
+ return
61
+ }
62
+ units += BigInt(count.tokens) * rateUnits(rate)
63
+ }
64
+ if (!bill) {
65
+ return {
66
+ authority: projection.authority,
67
+ status: 'unknown',
68
+ totalUsd: null,
69
+ estimateUsd: null,
70
+ priceCardDigest,
71
+ missing: ['no-bill'],
72
+ }
73
+ }
74
+ add(bill.uncachedInput, rates.input, 'input')
75
+ add(bill.cacheRead, rates.cachedInput, 'cache-read')
76
+ add(bill.output, rates.output, 'output')
77
+ if (bill.cacheWrite.kind === 'unknown') missing.push('cache-write:unknown')
78
+ if (bill.cacheWrite.kind === 'aggregate') add(bill.cacheWrite.tokens, rates.cacheWrite?.[bill.cacheWrite.rateKey], 'cache-write')
79
+ if (bill.cacheWrite.kind === 'details') {
80
+ for (const [key, bucket] of Object.entries(bill.cacheWrite.buckets)) {
81
+ add(bucket.tokens, rates.cacheWrite?.[bucket.rateKey], `cache-write:${key}`)
82
+ }
83
+ }
84
+ if (bill.reasoning.kind === 'unknown') missing.push('reasoning-contract:unknown')
85
+ if (bill.reasoning.kind === 'separate') add(bill.reasoning.tokens, rates.reasoning, 'reasoning')
86
+ const complete = projection.authority === 'final' && missing.length === 0
87
+ return {
88
+ authority: projection.authority,
89
+ status: complete ? 'complete' : projection.authority === 'final' ? 'incomplete' : 'provisional',
90
+ totalUsd: complete ? decimal(units) : null,
91
+ estimateUsd: decimal(units),
92
+ priceCardDigest,
93
+ missing,
94
+ }
95
+ }