dsh-model-billing 0.3.1 → 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 (4) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/client.js +46 -22
  3. package/host.js +156 -1
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-11-05
4
+
5
+ ### 变更
6
+
7
+ - 费用统计改为读取 Session 投影,不再调用已弃用的同步会话事件读取接口(DSH 0.2.2 起标记弃用,后续大版本可能移除);费用数字与直接折叠会话日志的结果一致。
8
+ - 切换模型或推理强度被拒绝时,改为菜单内浮层提示并自动消失,菜单不再因错误信息被撑高;模型目录本身加载失败时仍保留内联错误与「重新加载」按钮。
9
+
10
+ ### Changed
11
+
12
+ - Cost estimation now reads a Session projection instead of the deprecated synchronous session-event readers (deprecated since DSH 0.2.2 and removable in a later major version); the reported cost matches folding the session log directly.
13
+ - A rejected model or reasoning-effort switch now shows a self-dismissing overlay inside the picker instead of an inline error that grew the menu; a failed model-catalog load still shows the inline error with its retry button.
14
+
3
15
  ## [0.3.1] - 2026-11-05
4
16
 
5
17
  ### 变更
package/client.js CHANGED
@@ -751,7 +751,12 @@ window.__ModuleLoader__.load({
751
751
  .mb-picker-search::placeholder { color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary)); }
752
752
  .mb-picker-search:focus { border-color: var(--dsw-alias-state-business-primary); outline: none; }
753
753
  .mb-picker-list { min-height: 0; overflow-y: auto; padding: 0 2px; display: flex; flex-direction: column; }
754
- .mb-picker-group { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); background: var(--dsw-alias-menu-group-header-fill, var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1))); position: sticky; top: 0; z-index: 1; margin-top: 2px; padding: 10px 10px 6px; font-size: 11px; font-weight: 500; line-height: 16px; }
754
+ .mb-picker-group { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); background: var(--dsw-alias-menu-group-header-fill, var(--dsw-menu-surface-fill, var(--dsw-alias-bg-layer-1))); position: sticky; top: 0; z-index: 1; margin-top: 2px; padding: 5px 10px; font-size: 11px; font-weight: 500; line-height: 16px; }
755
+ /* The first group header opens the list, so the menu's own padding already
756
+ separates it from the top edge; it only drops the inter-group lead-in.
757
+ A negative margin cannot do this: the list is a scroll container, and the
758
+ overflow clip eats any lead-in that leaves its scrollport. */
759
+ .mb-picker-group:first-child { margin-top: 0; }
755
760
  .mb-picker-row { box-sizing: border-box; border-radius: var(--dsw-radius-md); width: auto; min-width: 100%; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: none; outline: none; align-items: center; gap: 8px; padding: 10px; font: inherit; display: flex; }
756
761
  .mb-picker-row + .mb-picker-row { margin-top: 2px; }
757
762
  .mb-picker-row:hover:not(:disabled), .mb-picker-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
@@ -773,6 +778,9 @@ window.__ModuleLoader__.load({
773
778
  .mb-picker-error { border-radius: var(--dsw-radius-md); background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); margin-bottom: 3px; padding: 6px 7px; font-size: 11px; line-height: 16px; }
774
779
  .mb-picker-retry { align-self: flex-start; margin: 0 4px 2px; }
775
780
  .mb-picker-status { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); padding: 4px 7px; font-size: 11px; line-height: 16px; }
781
+ .mb-picker-dot { width: 12px; height: 12px; border: 2px solid var(--dsw-alias-state-business-primary); border-right-color: transparent; border-radius: 50%; flex: none; animation: mb-picker-spin .7s linear infinite; }
782
+ @keyframes mb-picker-spin { to { transform: rotate(360deg); } }
783
+ .mb-picker-toast { position: absolute; left: 8px; right: 8px; bottom: 8px; z-index: 3; border-radius: var(--dsw-radius-md); background: var(--dsw-alias-state-error-primary); color: #fff; padding: 7px 9px; font-size: 12px; line-height: 16px; box-shadow: var(--dsw-elevation-prominent); }
776
784
  .mb-fatal { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 12px; }
777
785
  @media (max-width: 720px) {
778
786
  .mb-price-grid { grid-template-columns: 1fr; }
@@ -1758,7 +1766,10 @@ window.__ModuleLoader__.load({
1758
1766
  const [open, setOpen] = useState(false)
1759
1767
  const [query, setQuery] = useState('')
1760
1768
  const [selecting, setSelecting] = useState(false)
1761
- const [failure, setFailure] = useState(null)
1769
+ // The route being submitted, so its row can show a pending spinner, and a
1770
+ // transient rejection message that must not push the list around.
1771
+ const [pending, setPending] = useState(null)
1772
+ const [toast, setToast] = useState(null)
1762
1773
  const [menuPos, setMenuPos] = useState(null)
1763
1774
  // Roving cursor for arrow-key navigation inside the model list.
1764
1775
  const [activeIndex, setActiveIndex] = useState(0)
@@ -1816,6 +1827,13 @@ window.__ModuleLoader__.load({
1816
1827
  if (locked && open) setOpen(false)
1817
1828
  }, [locked, open])
1818
1829
 
1830
+ // A rejection toast clears itself; the menu is not blocked while it shows.
1831
+ useEffect(() => {
1832
+ if (toast === null) return undefined
1833
+ const timer = setTimeout(() => setToast(null), 4200)
1834
+ return () => clearTimeout(timer)
1835
+ }, [toast])
1836
+
1819
1837
  const rank = useMemo(() => {
1820
1838
  const map = new Map()
1821
1839
  for (const model of catalogSnap.data?.models ?? []) {
@@ -1952,14 +1970,15 @@ window.__ModuleLoader__.load({
1952
1970
  const close = (restoreFocus) => {
1953
1971
  setOpen(false)
1954
1972
  setQuery('')
1955
- setFailure(null)
1973
+ setToast(null)
1956
1974
  if (restoreFocus) queueMicrotask(() => triggerRef.current?.focus())
1957
1975
  }
1958
1976
 
1959
1977
  const submit = async (selection) => {
1960
1978
  if (busy) return
1961
1979
  setSelecting(true)
1962
- setFailure(null)
1980
+ setToast(null)
1981
+ setPending(selection)
1963
1982
  try {
1964
1983
  const result = directory !== null
1965
1984
  ? await directory.select(selection)
@@ -1969,14 +1988,16 @@ window.__ModuleLoader__.load({
1969
1988
  body: JSON.stringify({ sessionId, ...selection }),
1970
1989
  })
1971
1990
  if (result?.ok === false) {
1972
- setFailure(result.error?.message ?? translate('pickerEmpty'))
1991
+ // A refusal is a toast: the list stays where the user left it.
1992
+ setToast(result.error?.message ?? translate('pickerEmpty'))
1973
1993
  return
1974
1994
  }
1975
1995
  close(true)
1976
1996
  } catch (error) {
1977
- setFailure(error instanceof Error ? error.message : String(error))
1997
+ setToast(error instanceof Error ? error.message : String(error))
1978
1998
  } finally {
1979
1999
  setSelecting(false)
2000
+ setPending(null)
1980
2001
  }
1981
2002
  }
1982
2003
 
@@ -2006,7 +2027,7 @@ window.__ModuleLoader__.load({
2006
2027
  close(true)
2007
2028
  return
2008
2029
  }
2009
- setFailure(null)
2030
+ setToast(null)
2010
2031
  // Open the cursor on the row the user is already using, when there is one.
2011
2032
  const selectedAt = filtered.findIndex((entry) => entry.group.id === current?.provider && entry.model.id === current?.model)
2012
2033
  setActiveIndex(selectedAt < 0 ? 0 : selectedAt)
@@ -2022,7 +2043,6 @@ window.__ModuleLoader__.load({
2022
2043
  maxHeight: menuPos.maxHeight,
2023
2044
  ...menuPos.top === undefined ? { bottom: menuPos.bottom } : { top: menuPos.top },
2024
2045
  }
2025
- const errorText = failure ?? (state?.status === 'error' ? state.error : null)
2026
2046
 
2027
2047
  // Arrow keys move a roving cursor through the list. Enter and Space are
2028
2048
  // left to the focused row itself, so a <button> keeps its native
@@ -2123,18 +2143,20 @@ window.__ModuleLoader__.load({
2123
2143
  h('span', { className: 'mb-picker-model' }, entry.model.name),
2124
2144
  h(PriceLines, { pricing: price, translate })),
2125
2145
  h('span', { className: 'mb-picker-check' },
2126
- selected
2127
- ? h('svg', {
2128
- viewBox: '0 0 16 16',
2129
- width: 14,
2130
- height: 14,
2131
- fill: 'none',
2132
- stroke: 'currentColor',
2133
- strokeWidth: 1.7,
2134
- 'aria-hidden': true,
2135
- },
2136
- h('path', { d: 'M3.5 8.5l3 3 6-7' }))
2137
- : null)))
2146
+ pending !== null && pending.provider === entry.group.id && pending.model === entry.model.id
2147
+ ? h('span', { className: 'mb-picker-dot', 'aria-hidden': true })
2148
+ : selected
2149
+ ? h('svg', {
2150
+ viewBox: '0 0 16 16',
2151
+ width: 14,
2152
+ height: 14,
2153
+ fill: 'none',
2154
+ stroke: 'currentColor',
2155
+ strokeWidth: 1.7,
2156
+ 'aria-hidden': true,
2157
+ },
2158
+ h('path', { d: 'M3.5 8.5l3 3 6-7' }))
2159
+ : null)))
2138
2160
  })),
2139
2161
  effortChoices.length === 0 ? null : h(Fragment, null,
2140
2162
  h('div', { className: 'mb-picker-divider' }),
@@ -2149,13 +2171,15 @@ window.__ModuleLoader__.load({
2149
2171
  onClick: () => chooseEffort(choice.effort),
2150
2172
  }, choice.label)))),
2151
2173
  busy ? h('div', { className: 'mb-picker-status' }, translate('switching')) : null,
2152
- errorText === null ? null : h('div', { className: 'mb-picker-error' }, errorText),
2174
+ toast === null ? null : h('div', { className: 'mb-picker-toast', role: 'status', 'aria-live': 'polite' }, toast),
2175
+ state?.status === 'error'
2176
+ ? h('div', { className: 'mb-picker-error' }, state.error)
2177
+ : null,
2153
2178
  state?.status === 'error'
2154
2179
  ? h('button', {
2155
2180
  type: 'button',
2156
2181
  className: 'mb-btn mb-picker-retry',
2157
2182
  onClick: () => {
2158
- setFailure(null)
2159
2183
  directory.load().catch(() => {})
2160
2184
  },
2161
2185
  }, translate('pickerRetry'))
package/host.js CHANGED
@@ -1139,6 +1139,9 @@ export function apply(ctx) {
1139
1139
  const onSelect = (request, response) => {
1140
1140
  void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
1141
1141
  }
1142
+ // Register the samples projection before the routes: the unit folds lazily for
1143
+ // sessions that already existed, and drives every later event eagerly.
1144
+ void billingProjectionHandle(ctx)
1142
1145
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
1143
1146
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
1144
1147
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
@@ -1161,6 +1164,156 @@ function wantsRefresh(request) {
1161
1164
  }
1162
1165
  }
1163
1166
 
1167
+ // ---------------------------------------------------------------------------
1168
+ // Billing samples
1169
+ //
1170
+ // `session.ownEvents()` / `snapshotEvents()` are synchronous log reads that
1171
+ // 0.2.2 deprecates for production use, so the fold is fed by a session
1172
+ // projection instead: the registry drives it per committed event, folds the log
1173
+ // lazily for sessions that predate the registration, and checkpoints it with
1174
+ // every other unit. The state keeps a trimmed copy of each billing-relevant
1175
+ // event rather than the durable log, because the fold only reads a few fields.
1176
+ // ---------------------------------------------------------------------------
1177
+
1178
+ const BILLING_SAMPLES_KEY = 'model-billing/samples'
1179
+ const BILLING_SAMPLES_VERSION = 1
1180
+
1181
+ /** The last usage chunk of a durable assistant stream, kept alone. */
1182
+ function usageStreamRecord(stream) {
1183
+ if (!Array.isArray(stream)) return undefined
1184
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
1185
+ const record = stream[index]
1186
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage') {
1187
+ return { type: 'chunk', time: record.time, chunk: { type: 'usage', usage: record.chunk.usage } }
1188
+ }
1189
+ }
1190
+ return undefined
1191
+ }
1192
+
1193
+ /** One committed event trimmed to the fields `computeSessionUsage` reads. */
1194
+ function billingSample(event) {
1195
+ const seq = event?.seq
1196
+ const time = event?.time
1197
+ switch (event?.type) {
1198
+ case 'turn/start':
1199
+ case 'turn/end':
1200
+ return { type: event.type, seq, time, data: { turn: event.data?.turn } }
1201
+ case 'user/message': {
1202
+ const content = Array.isArray(event.data?.content)
1203
+ ? event.data.content.filter((block) => block?.type === 'text').map((block) => ({ type: 'text', text: block.text }))
1204
+ : []
1205
+ return { type: 'user/message', seq, time, data: { content } }
1206
+ }
1207
+ case 'request/header': {
1208
+ const config = event.data?.header?.config
1209
+ return { type: 'request/header', seq, time, data: { header: { config: { provider: config?.provider, model: config?.model } } } }
1210
+ }
1211
+ case 'assistant/message': {
1212
+ const source = event.data?.message?.source
1213
+ const usage = event.data?.usage
1214
+ const record = usageStreamRecord(event.data?.stream)
1215
+ return {
1216
+ type: 'assistant/message',
1217
+ seq,
1218
+ time,
1219
+ data: {
1220
+ turn: event.data?.turn,
1221
+ message: { source: { provider: source?.provider, model: source?.model } },
1222
+ ...usage === undefined || usage === null ? {} : { usage },
1223
+ ...record === undefined ? {} : { stream: [record] },
1224
+ },
1225
+ }
1226
+ }
1227
+ case 'assistant/attempt': {
1228
+ const record = usageStreamRecord(event.data?.stream)
1229
+ return {
1230
+ type: 'assistant/attempt',
1231
+ seq,
1232
+ time,
1233
+ // `turn` is what the fold keys the attempt on; without it the sample is dropped.
1234
+ data: { turn: event.data?.turn, step: event.data?.step, ...record === undefined ? {} : { stream: [record] } },
1235
+ }
1236
+ }
1237
+ case 'tool/call':
1238
+ return { type: 'tool/call', seq, time, data: { turn: event.data?.turn, callId: event.data?.callId, name: event.data?.name } }
1239
+ case 'tool/result': {
1240
+ const message = event.data?.message
1241
+ const error = event.data?.error
1242
+ const meta = event.data?.meta
1243
+ return {
1244
+ type: 'tool/result',
1245
+ seq,
1246
+ time,
1247
+ data: {
1248
+ turn: event.data?.turn,
1249
+ ...message === undefined ? {} : { message: { toolCallId: message?.toolCallId, isError: message?.isError } },
1250
+ ...error === undefined ? {} : { error },
1251
+ ...meta === undefined ? {} : { meta },
1252
+ },
1253
+ }
1254
+ }
1255
+ default:
1256
+ return undefined
1257
+ }
1258
+ }
1259
+
1260
+ /** Shape check for checkpoint restores; the plugin ships no zod, so it is hand-rolled. */
1261
+ const billingSamplesSchema = {
1262
+ parse(value) {
1263
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.samples)) {
1264
+ throw new TypeError('model-billing: unexpected samples projection state')
1265
+ }
1266
+ return value
1267
+ },
1268
+ }
1269
+
1270
+ const billingSamplesProjection = {
1271
+ key: BILLING_SAMPLES_KEY,
1272
+ stateVersion: BILLING_SAMPLES_VERSION,
1273
+ stateSchema: billingSamplesSchema,
1274
+ init(header, inheritedEventCount) {
1275
+ return { inheritedCut: Number(inheritedEventCount) || 0, samples: [] }
1276
+ },
1277
+ apply(state, event) {
1278
+ // A fork child owns only the events after its inherited prefix; the prefix
1279
+ // was already billed in the session it came from.
1280
+ if (typeof event?.seq === 'number' && event.seq < state.inheritedCut) return state
1281
+ const sample = billingSample(event)
1282
+ if (sample === undefined) return state
1283
+ return { inheritedCut: state.inheritedCut, samples: [...state.samples, sample] }
1284
+ },
1285
+ }
1286
+
1287
+ const billingProjectionPromises = new WeakMap()
1288
+
1289
+ /**
1290
+ * The billing projection handle for one Host context, or null on a Harness that
1291
+ * predates `sessionProjections`. Registration is memoized per context.
1292
+ */
1293
+ function billingProjectionHandle(ctx) {
1294
+ const cached = billingProjectionPromises.get(ctx)
1295
+ if (cached !== undefined) return cached
1296
+ let service
1297
+ try {
1298
+ service = ctx?.get?.('sessionProjections')
1299
+ } catch {
1300
+ service = undefined
1301
+ }
1302
+ const promise = service?.register === undefined
1303
+ ? Promise.resolve(null)
1304
+ : Promise.resolve(service.register(billingSamplesProjection)).catch(() => null)
1305
+ billingProjectionPromises.set(ctx, promise)
1306
+ return promise
1307
+ }
1308
+
1309
+ /** The billing samples of one session, from the projection or the log on old hosts. */
1310
+ async function sessionBillingSamples(ctx, session) {
1311
+ const handle = await billingProjectionHandle(ctx)
1312
+ if (handle !== null && handle !== undefined) return handle.stateOf(session).samples
1313
+ if (typeof session.snapshotEvents === 'function') return session.snapshotEvents(session.inheritedEventCount)
1314
+ return session.ownEvents()
1315
+ }
1316
+
1164
1317
  const USAGE_CACHE_LIMIT = 50
1165
1318
  const usageCache = new Map()
1166
1319
 
@@ -1203,7 +1356,7 @@ async function serveUsage(ctx, request, response) {
1203
1356
  return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
1204
1357
  }
1205
1358
  const state = await loadState(ctx)
1206
- const events = session.ownEvents()
1359
+ const events = await sessionBillingSamples(ctx, session)
1207
1360
  const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
1208
1361
  const billing = webToolBilling(ctx)
1209
1362
  const webKey = `${billing.search ?? 'free'}:${billing.fetch ?? 'free'}:${billing.start}`
@@ -1362,6 +1515,8 @@ function messageOf(error) {
1362
1515
  export const __test = {
1363
1516
  applySavedOrder,
1364
1517
  authorityOf,
1518
+ billingSample,
1519
+ billingSamplesProjection,
1365
1520
  builtinRatesFor,
1366
1521
  buildCatalog,
1367
1522
  catalogPricing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-billing",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Reorder models in the DSH composer picker, store three per-million-token prices, and estimate per-turn and session cost with built-in DeepSeek peak/off-peak rates.",
6
6
  "keywords": [