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 +22 -0
- package/README.md +4 -4
- package/client.js +50 -23
- package/host.js +161 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2127
|
-
? h('
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": [
|