dsh-all-usage 1.1.17 → 1.2.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/CHANGELOG.md +453 -386
- package/LICENSE +21 -21
- package/README.en.md +270 -0
- package/README.md +145 -273
- package/assets/model-icons/ai2-color.svg +1 -0
- package/assets/model-icons/ai21-brand-color.svg +1 -0
- package/assets/model-icons/aionlabs.svg +1 -0
- package/assets/model-icons/alephalpha.svg +1 -0
- package/assets/model-icons/baai.svg +1 -0
- package/assets/model-icons/baichuan-color.svg +1 -0
- package/assets/model-icons/baidu-color.svg +1 -0
- package/assets/model-icons/bytedance-color.svg +1 -0
- package/assets/model-icons/cohere-color.svg +1 -0
- package/assets/model-icons/hunyuan-color.svg +1 -0
- package/assets/model-icons/ibm.svg +1 -0
- package/assets/model-icons/iflytekcloud-color.svg +1 -0
- package/assets/model-icons/internlm-color.svg +1 -0
- package/assets/model-icons/kling-color.svg +1 -0
- package/assets/model-icons/liquid.svg +1 -0
- package/assets/model-icons/longcat-color.svg +1 -0
- package/assets/model-icons/manifest.json +739 -213
- package/assets/model-icons/microsoft-color.svg +1 -0
- package/assets/model-icons/mistral-color.svg +1 -0
- package/assets/model-icons/nousresearch.svg +1 -0
- package/assets/model-icons/nova-color.svg +1 -0
- package/assets/model-icons/nvidia-color.svg +1 -0
- package/assets/model-icons/perplexity-color.svg +1 -0
- package/assets/model-icons/sensenova-color.svg +1 -0
- package/assets/model-icons/skywork-color.svg +1 -0
- package/assets/model-icons/snowflake-color.svg +1 -0
- package/assets/model-icons/stepfun-color.svg +1 -0
- package/assets/model-icons/tencent-color.svg +1 -0
- package/assets/model-icons/tii-color.svg +1 -0
- package/assets/model-icons/upstage-color.svg +1 -0
- package/assets/model-icons/voyage-color.svg +1 -0
- package/assets/model-icons/xiaomimimo.svg +1 -0
- package/assets/model-icons/yandex.svg +1 -0
- package/assets/readme-architecture-en.svg +95 -0
- package/assets/readme-architecture-zh.svg +95 -0
- package/assets/readme-sync-en.svg +81 -0
- package/assets/readme-sync-zh.svg +81 -0
- package/assets/screenshots/en/01-overview.png +0 -0
- package/assets/screenshots/en/02-heatmap.png +0 -0
- package/assets/screenshots/en/03-model-usage.png +0 -0
- package/assets/screenshots/en/04-request-logs.png +0 -0
- package/assets/screenshots/en/05-cost-settings.png +0 -0
- package/assets/screenshots/zh/01-overview.png +0 -0
- package/assets/screenshots/zh/02-heatmap.png +0 -0
- package/assets/screenshots/zh/03-model-usage.png +0 -0
- package/assets/screenshots/zh/04-request-logs.png +0 -0
- package/assets/screenshots/zh/05-cost-settings.png +0 -0
- package/cordis.patch.yml +5 -5
- package/lib/aggregation.js +1842 -1112
- package/lib/client.js +1 -1
- package/lib/http.js +417 -370
- package/lib/icons.js +40 -0
- package/lib/ledger.js +1000 -531
- package/lib/plugin.js +347 -323
- package/lib/pricing-runtime.js +615 -551
- package/lib/pricing.js +1463 -1412
- package/lib/records-cache.js +113 -0
- package/lib/session-sync.js +976 -841
- package/lib/usage-core.js +33 -10
- package/lib/version-check.js +133 -0
- package/package.json +8 -3
- package/screenshots.json +10 -3
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
package/lib/http.js
CHANGED
|
@@ -1,370 +1,417 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return typeof value === 'string' ? value : undefined
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (words
|
|
46
|
-
if (words.slice(0,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
res
|
|
108
|
-
res.
|
|
109
|
-
res.setHeader('
|
|
110
|
-
res.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
let
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
settled
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
req.on('
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const {
|
|
160
|
-
const {
|
|
161
|
-
const {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
sendJson(res,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
sendJson(res,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
sendJson(res,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
await
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
sendJson(res,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { findModelIcon } from './icons.js'
|
|
3
|
+
|
|
4
|
+
// local and require a browser-originated capability for state-changing reads/writes.
|
|
5
|
+
function requestHeader(req, name) {
|
|
6
|
+
const headers = req && req.headers
|
|
7
|
+
if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
|
|
8
|
+
const value = headers[name.toLowerCase()]
|
|
9
|
+
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
|
|
10
|
+
return typeof value === 'string' ? value : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isLoopbackIpv4(address) {
|
|
14
|
+
const parts = address.split('.')
|
|
15
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ipv6Words(address) {
|
|
19
|
+
let value = address.toLowerCase()
|
|
20
|
+
if (value.includes('.')) {
|
|
21
|
+
const separator = value.lastIndexOf(':')
|
|
22
|
+
if (separator < 0) return null
|
|
23
|
+
const ipv4 = value.slice(separator + 1)
|
|
24
|
+
if (!isLoopbackIpv4(ipv4) && !ipv4.split('.').every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null
|
|
25
|
+
const parts = ipv4.split('.').map(Number)
|
|
26
|
+
value = value.slice(0, separator + 1) + (((parts[0] << 8) | parts[1]).toString(16)) + ':' + (((parts[2] << 8) | parts[3]).toString(16))
|
|
27
|
+
}
|
|
28
|
+
const halves = value.split('::')
|
|
29
|
+
if (halves.length > 2) return null
|
|
30
|
+
const left = halves[0] === '' ? [] : halves[0].split(':')
|
|
31
|
+
const right = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : []
|
|
32
|
+
if (halves.length === 1 && left.length !== 8) return null
|
|
33
|
+
const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0
|
|
34
|
+
if (zeroCount < (halves.length === 2 ? 1 : 0)) return null
|
|
35
|
+
const groups = left.concat(Array.from({ length: zeroCount }, () => '0'), right)
|
|
36
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null
|
|
37
|
+
return groups.map((group) => Number.parseInt(group, 16))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isLoopbackAddress(address) {
|
|
41
|
+
if (typeof address !== 'string') return false
|
|
42
|
+
const value = address.trim().toLowerCase()
|
|
43
|
+
if (isLoopbackIpv4(value)) return true
|
|
44
|
+
const words = ipv6Words(value)
|
|
45
|
+
if (words === null) return false
|
|
46
|
+
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) return true
|
|
47
|
+
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
|
|
48
|
+
const ipv4 = [(words[6] >> 8) & 255, words[6] & 255, (words[7] >> 8) & 255, words[7] & 255].join('.')
|
|
49
|
+
return isLoopbackIpv4(ipv4)
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLoopbackHostname(hostname) {
|
|
55
|
+
if (typeof hostname !== 'string') return false
|
|
56
|
+
const value = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
|
|
57
|
+
if (value === 'localhost') return true
|
|
58
|
+
return isLoopbackAddress(value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The DSH desktop shell serves the UI from its own scheme and forwards every
|
|
62
|
+
// request through Electron, which strips Origin (along with Cookie and
|
|
63
|
+
// Sec-Fetch-*) before the host sees it. A write therefore cannot present an
|
|
64
|
+
// Origin at all, and requiring one made every save from the desktop client fail
|
|
65
|
+
// with 403. What actually authorizes a write is the per-apply capability: it is
|
|
66
|
+
// unguessable and unreadable cross-origin (the read endpoints send no CORS
|
|
67
|
+
// headers), and a browser cannot attach it to a form POST. An Origin that is
|
|
68
|
+
// present must still be a trusted one, and the Host must still be loopback so a
|
|
69
|
+
// rebound DNS name cannot reach the API.
|
|
70
|
+
function isTrustedLocalOrigin(origin, hostUrl) {
|
|
71
|
+
if (origin === 'dsh-app://app' || origin.startsWith('dsh-app://')) return true
|
|
72
|
+
let originUrl
|
|
73
|
+
try {
|
|
74
|
+
originUrl = new URL(origin)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isTrustedLocalApiRequest(req, requireOrigin) {
|
|
82
|
+
const remoteAddress = req && req.socket && req.socket.remoteAddress
|
|
83
|
+
if (!isLoopbackAddress(remoteAddress)) return false
|
|
84
|
+
const host = requestHeader(req, 'host')
|
|
85
|
+
if (host === undefined) return false
|
|
86
|
+
let hostUrl
|
|
87
|
+
try {
|
|
88
|
+
hostUrl = new URL('http://' + host)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
93
|
+
if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
|
|
94
|
+
const origin = requestHeader(req, 'origin')
|
|
95
|
+
if (origin === undefined || origin === '') return requireOrigin !== true
|
|
96
|
+
return isTrustedLocalOrigin(origin, hostUrl)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasWriteToken(req, expected) {
|
|
100
|
+
const actual = requestHeader(req, 'x-all-usage-request-token')
|
|
101
|
+
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
102
|
+
const actualBytes = Buffer.from(actual)
|
|
103
|
+
const expectedBytes = Buffer.from(expected)
|
|
104
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sendJson(res, code, value) {
|
|
108
|
+
res.statusCode = code
|
|
109
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
110
|
+
res.setHeader('cache-control', 'no-store')
|
|
111
|
+
res.end(JSON.stringify(value))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readBody(req, maxBytes) {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const chunks = []
|
|
117
|
+
const declaredLength = Number(requestHeader(req, 'content-length'))
|
|
118
|
+
let size = Number.isFinite(declaredLength) && declaredLength > maxBytes ? maxBytes + 1 : 0
|
|
119
|
+
let tooLarge = size > maxBytes
|
|
120
|
+
let settled = false
|
|
121
|
+
const finish = (text, oversized) => {
|
|
122
|
+
if (settled) return
|
|
123
|
+
settled = true
|
|
124
|
+
resolve({ text, tooLarge: oversized })
|
|
125
|
+
}
|
|
126
|
+
req.on('data', (chunk) => {
|
|
127
|
+
if (tooLarge) return
|
|
128
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
129
|
+
size += value.length
|
|
130
|
+
if (size > maxBytes) {
|
|
131
|
+
tooLarge = true
|
|
132
|
+
chunks.length = 0
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
chunks.push(value)
|
|
136
|
+
})
|
|
137
|
+
req.on('end', () => finish(tooLarge ? '' : Buffer.concat(chunks).toString('utf8'), tooLarge))
|
|
138
|
+
req.on('error', () => finish('', false))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function registerRoutes(host) {
|
|
143
|
+
const { ctx, webServer, state } = host
|
|
144
|
+
const {
|
|
145
|
+
queryScopeFromRequest,
|
|
146
|
+
queryUsageScope,
|
|
147
|
+
queryRecords,
|
|
148
|
+
snapshot,
|
|
149
|
+
statusSnapshot,
|
|
150
|
+
} = host.aggregation
|
|
151
|
+
const {
|
|
152
|
+
pricingModelSearch,
|
|
153
|
+
pricingSnapshot,
|
|
154
|
+
updatePricingState,
|
|
155
|
+
persistPricing,
|
|
156
|
+
syncPricing,
|
|
157
|
+
loadHolidayCalendar,
|
|
158
|
+
} = host.pricing
|
|
159
|
+
const { runBaseline } = host.sessionSync
|
|
160
|
+
const { fetchBalance } = host.balance
|
|
161
|
+
const { setAlias } = host.aliases
|
|
162
|
+
const { drainLedgerWrites } = host.ledger
|
|
163
|
+
|
|
164
|
+
// ---------- HTTP data routes for the client half ----------
|
|
165
|
+
if (webServer !== undefined) {
|
|
166
|
+
const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
|
|
167
|
+
ctx.effect(() => webServer.register({
|
|
168
|
+
kind: 'exact',
|
|
169
|
+
path: '/api/all-usage/query',
|
|
170
|
+
handler: (req, res) => {
|
|
171
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
172
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
173
|
+
const parsed = queryScopeFromRequest(req)
|
|
174
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
175
|
+
sendJson(res, 200, queryUsageScope(parsed.scope))
|
|
176
|
+
},
|
|
177
|
+
}))
|
|
178
|
+
ctx.effect(() => webServer.register({
|
|
179
|
+
kind: 'exact',
|
|
180
|
+
path: '/api/all-usage/records',
|
|
181
|
+
// queryRecords may read a session document back from the ledger, so this
|
|
182
|
+
// handler is async end to end.
|
|
183
|
+
handler: async (req, res) => {
|
|
184
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
185
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
186
|
+
const parsed = queryScopeFromRequest(req)
|
|
187
|
+
if (!parsed.ok) { sendJson(res, 400, { ok: false, message: parsed.message }); return }
|
|
188
|
+
let limit = 50
|
|
189
|
+
let cursor
|
|
190
|
+
try {
|
|
191
|
+
const url = new URL(req.url || '/', 'http://all-usage.local')
|
|
192
|
+
const rawLimit = url.searchParams.get('limit')
|
|
193
|
+
if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
|
|
194
|
+
cursor = url.searchParams.get('cursor') || undefined
|
|
195
|
+
} catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
|
|
196
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) { sendJson(res, 400, { ok: false, message: 'invalid-limit' }); return }
|
|
197
|
+
const result = await queryRecords(parsed.scope, cursor, limit)
|
|
198
|
+
if (result.error !== undefined) { sendJson(res, result.error === 'stale-cursor' ? 409 : 400, { ok: false, message: result.error }); return }
|
|
199
|
+
sendJson(res, 200, result)
|
|
200
|
+
},
|
|
201
|
+
}))
|
|
202
|
+
ctx.effect(() => webServer.register({
|
|
203
|
+
kind: 'exact',
|
|
204
|
+
path: '/api/all-usage/pricing/models',
|
|
205
|
+
handler: async (req, res) => {
|
|
206
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
207
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
208
|
+
let query = ''
|
|
209
|
+
let limit = 20
|
|
210
|
+
try {
|
|
211
|
+
const url = new URL(req.url || '/', 'http://all-usage.local')
|
|
212
|
+
query = url.searchParams.get('q') || ''
|
|
213
|
+
const rawLimit = url.searchParams.get('limit')
|
|
214
|
+
if (rawLimit !== null && rawLimit !== '') limit = Number(rawLimit)
|
|
215
|
+
} catch (err) { sendJson(res, 400, { ok: false, message: 'bad-query' }); return }
|
|
216
|
+
if (query.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 50) { sendJson(res, 400, { ok: false, message: 'invalid-model-search' }); return }
|
|
217
|
+
await state.pricingReady
|
|
218
|
+
sendJson(res, 200, { items: pricingModelSearch(query, limit) })
|
|
219
|
+
},
|
|
220
|
+
}))
|
|
221
|
+
ctx.effect(() => webServer.register({
|
|
222
|
+
kind: 'exact',
|
|
223
|
+
path: '/api/all-usage/pricing',
|
|
224
|
+
handler: async (req, res) => {
|
|
225
|
+
if (req.method === 'GET') {
|
|
226
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
227
|
+
await state.pricingReady
|
|
228
|
+
sendJson(res, 200, pricingSnapshot())
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
232
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
|
|
233
|
+
const body = await readBody(req, 256 * 1024)
|
|
234
|
+
if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
|
|
235
|
+
let args = null
|
|
236
|
+
try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
|
|
237
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) { sendJson(res, 400, { ok: false, message: 'bad-pricing-request' }); return }
|
|
238
|
+
// Wait for the persisted pricing state (and the ledger it backfills)
|
|
239
|
+
// before mutating it: an early POST would otherwise be skipped because
|
|
240
|
+
// pricingUnit is still null and then overwritten by the loaded state.
|
|
241
|
+
await Promise.all([state.pricingReady, state.ledgerReady])
|
|
242
|
+
const result = await updatePricingState(args.pricing || args, args.backfill === true, args.repriceTemporal === true)
|
|
243
|
+
await persistPricing()
|
|
244
|
+
await drainLedgerWrites()
|
|
245
|
+
sendJson(res, 200, { ok: true, backfill: result, pricing: pricingSnapshot() })
|
|
246
|
+
},
|
|
247
|
+
}))
|
|
248
|
+
ctx.effect(() => webServer.register({
|
|
249
|
+
kind: 'exact',
|
|
250
|
+
path: '/api/all-usage/pricing/sync',
|
|
251
|
+
handler: async (req, res) => {
|
|
252
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
253
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
|
|
254
|
+
// Backfill iterates the loaded ledger, so wait for it like the pricing
|
|
255
|
+
// POST does; otherwise an early sync answers with an empty backfill.
|
|
256
|
+
await Promise.all([state.pricingReady, state.ledgerReady])
|
|
257
|
+
const result = await syncPricing(true)
|
|
258
|
+
sendJson(res, result.ok ? 200 : 502, result)
|
|
259
|
+
},
|
|
260
|
+
}))
|
|
261
|
+
ctx.effect(() => webServer.register({
|
|
262
|
+
kind: 'exact',
|
|
263
|
+
path: '/api/all-usage/pricing/holidays',
|
|
264
|
+
handler: async (req, res) => {
|
|
265
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
266
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
|
|
267
|
+
const body = await readBody(req, 8 * 1024)
|
|
268
|
+
if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
|
|
269
|
+
let args = null
|
|
270
|
+
try { args = JSON.parse(body.text) } catch (err) { /* invalid json */ }
|
|
271
|
+
const year = args !== null && typeof args === 'object' && !Array.isArray(args) ? Number(args.year) : NaN
|
|
272
|
+
if (!Number.isSafeInteger(year) || year < 2000 || year > 2100) { sendJson(res, 400, { ok: false, message: 'bad-holiday-request' }); return }
|
|
273
|
+
// Fetch only: the client freezes the returned dates into a pricing policy.
|
|
274
|
+
const result = await loadHolidayCalendar(year)
|
|
275
|
+
sendJson(res, result.ok ? 200 : 502, result)
|
|
276
|
+
},
|
|
277
|
+
}))
|
|
278
|
+
ctx.effect(() => webServer.register({
|
|
279
|
+
kind: 'exact',
|
|
280
|
+
path: '/api/all-usage/status',
|
|
281
|
+
handler: (req, res) => {
|
|
282
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
283
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
284
|
+
if (!state.scan.started) void runBaseline()
|
|
285
|
+
sendJson(res, 200, statusSnapshot())
|
|
286
|
+
},
|
|
287
|
+
}))
|
|
288
|
+
ctx.effect(() => webServer.register({
|
|
289
|
+
kind: 'exact',
|
|
290
|
+
path: '/api/all-usage/client-env',
|
|
291
|
+
handler: (req, res) => {
|
|
292
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
293
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
294
|
+
// Diagnostics only: the client reports what the host exposes (user agent,
|
|
295
|
+
// window-controls-overlay geometry, the strip it reserved). Truncated and
|
|
296
|
+
// kept in memory for the life of the process.
|
|
297
|
+
try {
|
|
298
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
299
|
+
const read = (name, limit) => {
|
|
300
|
+
const value = url.searchParams.get(name)
|
|
301
|
+
return value === null ? null : value.slice(0, limit)
|
|
302
|
+
}
|
|
303
|
+
state.clientEnv = {
|
|
304
|
+
reportedAt: Date.now(),
|
|
305
|
+
ua: read('ua', 200),
|
|
306
|
+
wco: read('wco', 40),
|
|
307
|
+
inset: read('inset', 12),
|
|
308
|
+
insetSource: read('src', 24),
|
|
309
|
+
titlebar: read('tb', 24),
|
|
310
|
+
viewport: read('vp', 24),
|
|
311
|
+
modalRect: read('modal', 40),
|
|
312
|
+
dialogRect: read('dlg', 40),
|
|
313
|
+
headRect: read('head', 40),
|
|
314
|
+
closeRect: read('close', 40),
|
|
315
|
+
}
|
|
316
|
+
} catch (err) { /* ignore a malformed diagnostic */ }
|
|
317
|
+
sendJson(res, 200, { ok: true })
|
|
318
|
+
},
|
|
319
|
+
}))
|
|
320
|
+
ctx.effect(() => webServer.register({
|
|
321
|
+
kind: 'exact',
|
|
322
|
+
path: '/api/all-usage',
|
|
323
|
+
handler: async (req, res) => {
|
|
324
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
325
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
326
|
+
// The snapshot embeds the pricing summary; wait for the persisted
|
|
327
|
+
// configuration so the first response is never a default empty state
|
|
328
|
+
// that the client would keep as its pricing baseline.
|
|
329
|
+
await Promise.all([state.ledgerReady, state.pricingReady])
|
|
330
|
+
if (!state.scan.started) void runBaseline()
|
|
331
|
+
// The browser picks one timezone calendar for its whole session, so it
|
|
332
|
+
// asks for that copy only: the other one was half of the payload.
|
|
333
|
+
let utc = null
|
|
334
|
+
try {
|
|
335
|
+
const raw = new URL(req.url ?? '/', 'http://x').searchParams.get('utc')
|
|
336
|
+
if (raw === '0' || raw === '1') utc = raw === '1'
|
|
337
|
+
} catch (err) { /* default: both copies */ }
|
|
338
|
+
sendJson(res, 200, snapshot(utc))
|
|
339
|
+
},
|
|
340
|
+
}))
|
|
341
|
+
ctx.effect(() => webServer.register({
|
|
342
|
+
kind: 'exact',
|
|
343
|
+
path: '/api/all-usage/icon',
|
|
344
|
+
handler: async (req, res) => {
|
|
345
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
346
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
347
|
+
let key = ''
|
|
348
|
+
try {
|
|
349
|
+
key = new URL(req.url ?? '/', 'http://x').searchParams.get('key') ?? ''
|
|
350
|
+
} catch (err) { /* default */ }
|
|
351
|
+
// Resolution is by manifest key only, so a crafted value can never name a
|
|
352
|
+
// path. The body is immutable per release, hence the long cache.
|
|
353
|
+
const icon = await findModelIcon(key)
|
|
354
|
+
if (icon === null) { sendJson(res, 404, { ok: false, message: 'icon-not-found' }); return }
|
|
355
|
+
res.statusCode = 200
|
|
356
|
+
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
|
|
357
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable')
|
|
358
|
+
res.setHeader('etag', icon.etag)
|
|
359
|
+
res.setHeader('content-length', String(icon.body.length))
|
|
360
|
+
res.end(icon.body)
|
|
361
|
+
},
|
|
362
|
+
}))
|
|
363
|
+
ctx.effect(() => webServer.register({
|
|
364
|
+
kind: 'exact',
|
|
365
|
+
path: '/api/all-usage/version',
|
|
366
|
+
handler: async (req, res) => {
|
|
367
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
368
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
369
|
+
let force = false
|
|
370
|
+
try {
|
|
371
|
+
force = new URL(req.url ?? '/', 'http://x').searchParams.get('force') === '1'
|
|
372
|
+
} catch (err) { /* default */ }
|
|
373
|
+
// The host caches the answer, so a dashboard left open does not hammer the
|
|
374
|
+
// registry; force=1 only comes from the refresh button.
|
|
375
|
+
sendJson(res, 200, await host.versionCheck.read(force))
|
|
376
|
+
},
|
|
377
|
+
}))
|
|
378
|
+
ctx.effect(() => webServer.register({
|
|
379
|
+
kind: 'exact',
|
|
380
|
+
path: '/api/all-usage/balance',
|
|
381
|
+
handler: async (req, res) => {
|
|
382
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
383
|
+
// Browsers may omit Origin on same-origin GET; the process token remains required.
|
|
384
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
|
|
385
|
+
let force = false
|
|
386
|
+
try {
|
|
387
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
388
|
+
force = url.searchParams.get('force') === '1'
|
|
389
|
+
} catch (err) { /* default */ }
|
|
390
|
+
sendJson(res, 200, await fetchBalance(force))
|
|
391
|
+
},
|
|
392
|
+
}))
|
|
393
|
+
ctx.effect(() => webServer.register({
|
|
394
|
+
kind: 'exact',
|
|
395
|
+
path: '/api/all-usage/alias',
|
|
396
|
+
handler: async (req, res) => {
|
|
397
|
+
if (req.method !== 'POST') {
|
|
398
|
+
res.statusCode = 405
|
|
399
|
+
res.end()
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, state.requestToken)) { rejectRequest(res); return }
|
|
403
|
+
const body = await readBody(req, 16 * 1024)
|
|
404
|
+
if (body.tooLarge) { sendJson(res, 413, { ok: false, message: 'request-too-large' }); return }
|
|
405
|
+
let args = null
|
|
406
|
+
try {
|
|
407
|
+
args = JSON.parse(body.text)
|
|
408
|
+
} catch (err) { /* invalid json */ }
|
|
409
|
+
const validAliasRequest = args !== null && args !== undefined && typeof args === 'object' && !Array.isArray(args) && typeof args.workspaceId === 'string' && args.workspaceId.length > 0 && args.workspaceId.length <= 256 && typeof args.alias === 'string'
|
|
410
|
+
const result = validAliasRequest
|
|
411
|
+
? setAlias(args.workspaceId, args.alias)
|
|
412
|
+
: { ok: false, message: 'bad-request', aliases: Object.assign({}, state.aliases) }
|
|
413
|
+
sendJson(res, result.ok ? 200 : 400, result)
|
|
414
|
+
},
|
|
415
|
+
}))
|
|
416
|
+
}
|
|
417
|
+
}
|