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.
- package/CHANGELOG.md +12 -0
- package/client.js +46 -22
- package/host.js +156 -1
- 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:
|
|
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'))
|
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
|
|
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.
|
|
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": [
|