dsh-model-billing 0.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
15
+ ## [0.3.1] - 2026-11-05
16
+
17
+ ### 变更
18
+
19
+ - 只对 DeepSeek 独立的 Search / Fetch API(`deepseek-search-api` / `deepseek-fetch-api`)计入按次费;Messages 搜索(`deepseek-official`)的检索在模型回合内完成,费用已随该次请求的 token 计入,不再重复按次收费。
20
+
21
+ ### Changed
22
+
23
+ - Per-request Search/Fetch fees now apply only to DeepSeek's standalone Search / Fetch APIs (`deepseek-search-api` / `deepseek-fetch-api`). Messages search (`deepseek-official`) runs inside a model turn, so its cost already arrives as that turn's tokens and is no longer charged per request.
24
+
3
25
  ## [0.3.0] - 2026-11-02
4
26
 
5
27
  ### 新增
package/README.md CHANGED
@@ -34,7 +34,7 @@
34
34
  - **逐轮费用与使用模型**:每轮结束后在轮尾显示这一轮使用的模型,以及该轮费用和缓存命中率。
35
35
  - **可配置轮尾**:在设置中总开关轮尾显示,并可分别开关使用模型、模型提供商、费用、token、缓存命中率、文字标签,调整字号、间距和字重;也可以关闭选择器接管,恢复 DSH 内置模型选择器。输入框下方的费用估算另有独立开关。
36
36
  - **本会话费用**:输入框下方显示本会话累计估算、最近一轮费用和缓存命中率;展开后可查看按轮次和按模型的明细。
37
- - **DeepSeek Search/Fetch 按次费**:当 DSH 的 Web 搜索或抓取使用 DeepSeek 自带 provider 时,按官方 ¥5 / 1,000 次成功请求计入合计,并在费用明细里单独列出搜索、抓取的计费次数。
37
+ - **DeepSeek Search/Fetch 按次费**:当 DSH 的 Web 搜索或抓取使用 DeepSeek 独立的 Search / Fetch API 时,按官方 ¥5 / 1,000 次成功请求计入合计,并在费用明细里单独列出搜索、抓取的计费次数。
38
38
  - **历史价格与未定价路由**:设置页的“历史价格”区可以为已移出配置的模型补价、改价或删除,不必把模型加回列表;从模型列表删除模型不会删除它的价格,历史调用仍按保存的价格计算,查不到名称时显示原始模型 id。缺价时显示统一提示,可在设置里关闭这条提示。
39
39
  - **DeepSeek 也计入金额**:每条 usage 按事件发生时的北京时间自动选择高峰或空闲价,与第三方模型一起进入统计。
40
40
 
@@ -133,7 +133,7 @@ npm 包和 GitHub Release 使用相同版本号,两个渠道分别发布:
133
133
  - 一轮里的多次模型尝试(重试、失败等)会分别计入;失败调用如果 provider 没有返回 usage,则无法计入。
134
134
  - 只统计当前载入 Host 的会话;未打开的历史会话在进入后按需计算。
135
135
  - provider 返回的 `totalTokens` 与三档明细不一致时,保留三档明细并把该轮标为“部分未定价/不完整”,不再整条丢弃,避免大幅少报。
136
- - 当 DSH 的 Web 搜索或抓取使用 DeepSeek 自带的 `deepseek-official` / `deepseek-search-api` / `deepseek-fetch-api` 时,按 DeepSeek 官方价把 **Search/Fetch 按次费**(¥5 / 1,000 次成功请求,2026-11-01 00:00 北京时间起)计入合计;DuckDuckGo、Tavily、普通 HTTP 抓取等不收费的 provider 不会被计费。一次 `web_search` 含多个 query 时按一次成功工具调用计,可能低估其中多个 query 各自的按次费。
136
+ - 当 DSH 的 Web 搜索或抓取使用 DeepSeek 独立的 `deepseek-search-api` / `deepseek-fetch-api` 时,按 DeepSeek 官方价把 **Search/Fetch 按次费**(¥5 / 1,000 次成功请求,2026-11-01 00:00 北京时间起)计入合计;Messages 搜索(`deepseek-official`)在模型回合内完成,费用已随该次请求的 token 计入,因此不再重复收按次费。DuckDuckGo、Tavily、普通 HTTP 抓取等不收费的 provider 不会被计费。一次 `web_search` 含多个 query 时按一次成功工具调用计,可能低估其中多个 query 各自的按次费。
137
137
  - 第三方价格是静态配置,不会自动跟随官网调价;内置节假日表目前只覆盖 2026 年,2027 年起需更新 `DEEPSEEK_HOLIDAYS`。
138
138
 
139
139
  ### 开发
@@ -164,7 +164,7 @@ node --test test/*.test.mjs
164
164
  - **Per-turn model and amount** at the end of each turn, together with the turn's cache-hit rate.
165
165
  - **Configurable turn tail**: a master switch for the turn tail, plus individual toggles for models, provider names, cost, tokens, cache-hit rate, and text labels; adjust font size, spacing, and weight; or disable the picker takeover to restore the built-in DSH picker. The composer cost estimate has its own switch.
166
166
  - **Session summary** below the composer plus a detail panel grouped by turn and by model.
167
- - **DeepSeek Search/Fetch per-request fees**: when DSH web search or fetch runs on DeepSeek's own providers, the official fee of ¥5 per 1,000 successful requests is included, with search and fetch billed counts shown separately in the detail panel.
167
+ - **DeepSeek Search/Fetch per-request fees**: when DSH web search or fetch runs on DeepSeek's standalone Search / Fetch APIs, the official fee of ¥5 per 1,000 successful requests is included, with search and fetch billed counts shown separately in the detail panel.
168
168
  - **Historical prices and unpriced routes**: the "Historical prices" section in Settings lets you add, edit, or remove prices for models no longer in your order without re-adding them. Removing a model from the list keeps its price, so historical calls still bill correctly; when the name is unknown, the raw model id is shown. Unpriced calls share one notice that can be switched off.
169
169
  - **DeepSeek is billed too**, using the same three-price formula and the event's timestamp.
170
170
 
@@ -235,7 +235,7 @@ Order and prices live in `~/.dsh/model-billing.json` (or `$DSH_HOME/model-billin
235
235
  - Multiple attempts within a turn are counted separately; failed calls without provider usage cannot be counted.
236
236
  - Only sessions loaded by the current Host are counted.
237
237
  - When a provider's `totalTokens` disagrees with its three buckets, the buckets are kept and the turn is marked partial instead of being dropped, which avoids large under-reporting.
238
- - When DSH web search or fetch runs on DeepSeek's own `deepseek-official`, `deepseek-search-api`, or `deepseek-fetch-api` providers, the DeepSeek **Search/Fetch per-request fee** (¥5 per 1,000 successful requests, from 2026-11-01 00:00 Beijing time) is included. Free providers such as DuckDuckGo, Tavily, and plain HTTP fetch are never charged. A `web_search` call containing several queries is counted as one successful tool call, so the per-query part may be understated.
238
+ - When DSH web search or fetch runs on DeepSeek's standalone `deepseek-search-api` / `deepseek-fetch-api` providers, the DeepSeek **Search/Fetch per-request fee** (¥5 per 1,000 successful requests, from 2026-11-01 00:00 Beijing time) is included. Messages search (`deepseek-official`) runs inside a model turn, so its cost already arrives as that turn's tokens and is not charged again per request. Free providers such as DuckDuckGo, Tavily, and plain HTTP fetch are never charged. A `web_search` call containing several queries is counted as one successful tool call, so the per-query part may be understated.
239
239
  - Third-party prices are static and are not refreshed automatically. The bundled holiday table covers 2026 only; update `DEEPSEEK_HOLIDAYS` for 2027 onward.
240
240
 
241
241
  ### Development
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'))
@@ -2260,7 +2284,10 @@ window.__ModuleLoader__.load({
2260
2284
  ctx.slots.inject('settings.section', () => ctx.slots.register({
2261
2285
  name: 'settings.section',
2262
2286
  id: 'model-billing',
2263
- order: 12,
2287
+ // Native sections run -10..20 (account, general, models, plugins,
2288
+ // agent presets); third-party pages start at 25 so they sit below
2289
+ // the native group instead of splitting it.
2290
+ order: 25,
2264
2291
  label: () => t('nav'),
2265
2292
  }, SettingsSection))
2266
2293
 
package/host.js CHANGED
@@ -30,7 +30,11 @@ const KEY_SEPARATOR = '\u0000'
30
30
  // tool/result per invocation, so the fee can be folded from the session log.
31
31
  const DEEPSEEK_WEB_TOOL_START = Date.parse('2026-11-01T00:00:00+08:00')
32
32
  const DEEPSEEK_WEB_TOOL_PRICE = 0.005
33
- const DEEPSEEK_SEARCH_PROVIDERS = new Set(['deepseek-official', 'deepseek-search-api'])
33
+ // Only the standalone Search/Fetch APIs are billed per request. The Messages
34
+ // search provider (`deepseek-official`) runs Anthropic's `web_search_20250305`
35
+ // server tool inside a model turn, so its cost already arrives as prompt and
36
+ // completion tokens; charging ¥0.005 there would bill the same call twice.
37
+ const DEEPSEEK_SEARCH_PROVIDERS = new Set(['deepseek-search-api'])
34
38
  const DEEPSEEK_FETCH_PROVIDERS = new Set(['deepseek-fetch-api'])
35
39
  const DISPLAY_DEFAULTS = Object.freeze({
36
40
  showTurnTail: true,
@@ -1135,6 +1139,9 @@ export function apply(ctx) {
1135
1139
  const onSelect = (request, response) => {
1136
1140
  void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
1137
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)
1138
1145
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
1139
1146
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
1140
1147
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
@@ -1157,6 +1164,156 @@ function wantsRefresh(request) {
1157
1164
  }
1158
1165
  }
1159
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
+
1160
1317
  const USAGE_CACHE_LIMIT = 50
1161
1318
  const usageCache = new Map()
1162
1319
 
@@ -1199,7 +1356,7 @@ async function serveUsage(ctx, request, response) {
1199
1356
  return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
1200
1357
  }
1201
1358
  const state = await loadState(ctx)
1202
- const events = session.ownEvents()
1359
+ const events = await sessionBillingSamples(ctx, session)
1203
1360
  const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
1204
1361
  const billing = webToolBilling(ctx)
1205
1362
  const webKey = `${billing.search ?? 'free'}:${billing.fetch ?? 'free'}:${billing.start}`
@@ -1358,6 +1515,8 @@ function messageOf(error) {
1358
1515
  export const __test = {
1359
1516
  applySavedOrder,
1360
1517
  authorityOf,
1518
+ billingSample,
1519
+ billingSamplesProjection,
1361
1520
  builtinRatesFor,
1362
1521
  buildCatalog,
1363
1522
  catalogPricing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-billing",
3
- "version": "0.3.0",
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": [