@goodandready/dsh-cost-meter 0.8.9 → 0.8.11

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 (2) hide show
  1. package/lib/index.js +106 -37
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -38,21 +38,50 @@ export function isLoopback(value) {
38
38
  }
39
39
 
40
40
  export function isTrustedCaller(request) {
41
- const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress
42
- if (isLoopback(remote)) return true
43
- const site = request?.headers?.['sec-fetch-site']
44
- if (site === 'same-origin' || site === 'same-site') return true
41
+ if (!request) return false
42
+ const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
43
+ const site = request.headers?.['sec-fetch-site']
45
44
  if (site === 'cross-site') return false
46
- const origin = request?.headers?.['origin']
47
- const host = request?.headers?.['host']
48
- if (origin && host) {
45
+
46
+ const host = request.headers?.['x-forwarded-host'] || request.headers?.['host']
47
+ const origin = request.headers?.['origin']
48
+ if (origin) {
49
49
  try {
50
50
  const url = new URL(origin)
51
- if (url.host === host) return true
52
- } catch (e) {
53
- /* invalid url */
51
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
52
+ if (host && url.host.toLowerCase() === host.toLowerCase()) return true
53
+ if (isLoopback(url.hostname) && isLoopback(remote)) return true
54
+ return false
55
+ }
56
+ // Non-http/https origin (e.g. dsh-app://app in DSH Desktop)
57
+ if (isLoopback(remote)) return true
58
+ return false
59
+ } catch {
60
+ return false
54
61
  }
55
62
  }
63
+
64
+ const referer = request.headers?.['referer']
65
+ if (referer) {
66
+ try {
67
+ const url = new URL(referer)
68
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
69
+ if (host && url.host.toLowerCase() === host.toLowerCase()) return true
70
+ if (isLoopback(url.hostname) && isLoopback(remote)) return true
71
+ return false
72
+ }
73
+ // Non-http/https referer (e.g. dsh-app://app/ in DSH Desktop)
74
+ if (isLoopback(remote)) return true
75
+ return false
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ if (site === 'same-site') return false
82
+ if (site === 'same-origin') return true
83
+ if (isLoopback(remote)) return true
84
+
56
85
  return false
57
86
  }
58
87
 
@@ -167,15 +196,35 @@ export function slotOf(timeMs) {
167
196
  return Math.floor((d.getUTCHours() * 60 + d.getUTCMinutes()) / 30)
168
197
  }
169
198
 
170
- const usageByModelProjection = {
199
+ const modelSlotsSchema = zod.record(zod.string(), bucketSchema)
200
+
201
+ const costByModelViewSchema = zod.object({
202
+ models: zod.array(zod.object({
203
+ provider: zod.string(),
204
+ model: zod.string(),
205
+ slots: modelSlotsSchema,
206
+ }).strict()),
207
+ }).strict()
208
+
209
+ const costByModelStateSchema = zod.object({
210
+ route: zod.object({
211
+ provider: zod.string(),
212
+ model: zod.string(),
213
+ }).nullable(),
214
+ byKey: zod.record(zod.string(), modelSlotsSchema),
215
+ last: zod.object({
216
+ turn: zod.number().int().nonnegative(),
217
+ step: zod.number().int().nonnegative(),
218
+ key: zod.string(),
219
+ slot: zod.string(),
220
+ buckets: bucketSchema,
221
+ }).nullable(),
222
+ }).strict()
223
+
224
+ export const usageByModelProjection = {
171
225
  key: 'costByModel',
172
- schema: zod.object({
173
- models: zod.array(zod.object({
174
- provider: zod.string(),
175
- model: zod.string(),
176
- slots: zod.record(zod.string(), bucketSchema),
177
- })),
178
- }).strict(),
226
+ stateVersion: 2,
227
+ stateSchema: costByModelStateSchema,
179
228
  init: () => ({ route: null, byKey: {}, last: null }),
180
229
  apply: (state, event) => {
181
230
  if (event.type === 'request/header') {
@@ -211,17 +260,19 @@ const usageByModelProjection = {
211
260
  last: { turn: sample.turn, step: sample.step, key, slot, buckets },
212
261
  }
213
262
  },
214
- view: (state) => ({
215
- models: Object.keys(state.byKey).map((key) => {
216
- const slash = key.indexOf('/')
217
- return {
218
- provider: key.slice(0, slash),
219
- model: key.slice(slash + 1),
220
- slots: state.byKey[key],
221
- }
263
+ wire: {
264
+ viewSchema: costByModelViewSchema,
265
+ view: (state) => ({
266
+ models: Object.keys(state.byKey).map((key) => {
267
+ const slash = key.indexOf('/')
268
+ return {
269
+ provider: key.slice(0, slash),
270
+ model: key.slice(slash + 1),
271
+ slots: state.byKey[key],
272
+ }
273
+ }),
222
274
  }),
223
- }),
224
- stateVersion: 2,
275
+ },
225
276
  }
226
277
 
227
278
  /** The agent's default route, read off settings descriptors when available. */
@@ -309,19 +360,37 @@ export function apply(ctx, config) {
309
360
  }
310
361
  }
311
362
 
312
- // Live config from the GUI, with the composition entry as the base layer.
313
- ctx.inject(['settings'], (sctx) => {
314
- const scope = sctx.settings.register(NS, Config, { base: config })
315
- current = scope.get() ?? config
316
- scope.watch((next) => {
317
- current = next
363
+ // Live config reaction from GUI / settings service
364
+ ctx.on('settings/updated', (ns, next) => {
365
+ if (ns === NS && next) {
366
+ current = { ...config, ...next }
367
+ clearResolveCache()
368
+ }
369
+ })
370
+
371
+ ctx.on('settings/document-updated', (ns) => {
372
+ if (!ns || ns === NS) {
318
373
  clearResolveCache()
319
- })
374
+ }
375
+ })
376
+
377
+ ctx.inject(['settings'], (sctx) => {
378
+ if (typeof sctx.settings?.describe === 'function') {
379
+ try {
380
+ const desc = sctx.settings.describe(NS)
381
+ if (desc?.value && typeof desc.value === 'object') {
382
+ current = { ...config, ...desc.value }
383
+ clearResolveCache()
384
+ }
385
+ } catch {
386
+ /* namespace might not have descriptor yet */
387
+ }
388
+ }
320
389
  })
321
390
 
322
391
  // Hook the session projection so the widget can show spend breakdown.
323
- ctx.inject(['chatSessionProjections'], (pctx) => {
324
- pctx.chatSessionProjections.register(usageByModelProjection)
392
+ ctx.inject(['sessionProjections'], (pctx) => {
393
+ pctx.sessionProjections.register(usageByModelProjection)
325
394
  })
326
395
 
327
396
  // Route 1: POST /dsh-cost-meter/refresh (manual catalog reload)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",