dsh-plugin-usage 0.3.0 → 0.5.0
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/README.md +104 -9
- package/image-1.png +0 -0
- package/image.png +0 -0
- package/lib/client/index.js +1710 -111
- package/lib/host/fold-cache.js +117 -0
- package/lib/host/fold.js +11 -0
- package/lib/host/holidays.js +104 -0
- package/lib/host/index.js +335 -30
- package/lib/host/peak.js +107 -0
- package/lib/host/prices.js +876 -0
- package/lib/host/pricing.js +349 -0
- package/lib/host/query.js +60 -2
- package/lib/host/rpc.js +69 -36
- package/lib/host/scan.js +231 -12
- package/lib/host/settings.js +119 -76
- package/lib/host/state.js +18 -2
- package/lib/host/typert.js +221 -0
- package/package.json +13 -6
- package/screenshots.json +4 -0
- package/lib/host/client-source.js +0 -34
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference price table and the published peak rule, as one lookup with a
|
|
3
|
+
* visible resolution ladder.
|
|
4
|
+
*
|
|
5
|
+
* The problem this file exists for: a model id stored in a session log is not
|
|
6
|
+
* the key of any price table. The same model arrives as `deepseek-flash`,
|
|
7
|
+
* `deepseek-v4.1-flash`, `deepseek/deepseek-v4.1-flash`,
|
|
8
|
+
* `group/auto-deepseek-v4-1-flash`, or under a retired alias; the table is keyed
|
|
9
|
+
* by whatever the vendor — or opencode Zen — called it on the day the row was
|
|
10
|
+
* written. Guessing that mapping with string similarity silently prices a model
|
|
11
|
+
* at someone else's rate (a 0.62 similarity threshold once billed
|
|
12
|
+
* `deepseek-v4-flash` at 0.14/0.28 instead of 0.30/1.20), so every level below
|
|
13
|
+
* is deterministic and every outcome is reported.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order (first hit wins), in ONE function:
|
|
16
|
+
* 1. the user's explicit `prices` entry how: 'custom'
|
|
17
|
+
* 2. a match the user confirmed in the dialog how: 'confirmed'
|
|
18
|
+
* 3. a normalized literal match on the table how: 'normalized'
|
|
19
|
+
* 4. the alias table how: 'alias'
|
|
20
|
+
* 5. a variant suffix on a published model how: 'variant'
|
|
21
|
+
* anything else how: 'unmatched'
|
|
22
|
+
*
|
|
23
|
+
* Literal beats everything inferred, which is what makes `-free` behave: an id
|
|
24
|
+
* the table really publishes (`glm-5.3-flash-free`) is an EXACT match and keeps
|
|
25
|
+
* its own published price (0 while the promotion runs); only a `-free` variant
|
|
26
|
+
* the table does not carry falls through to the variant rung, and that one is
|
|
27
|
+
* reported as fuzzy. The old order put the twin first, so every `-free` id —
|
|
28
|
+
* including the ones the table publishes — read as 模糊 and was billed at its
|
|
29
|
+
* paid sibling, i.e. the promo's real price of 0 was replaced by a guess.
|
|
30
|
+
*
|
|
31
|
+
* The variant rung is a closed list of rename suffixes (`-free`, `-exp`,
|
|
32
|
+
* `-fast`, `-latest`, a four-digit date), each resolved against a literal table
|
|
33
|
+
* row — never a similarity score. That is how `deepseek-v4.1-flash-fast` and
|
|
34
|
+
* `deepseek-v4.1-flash-1017` land on `deepseek-v4.1-flash` as 模糊 instead of
|
|
35
|
+
* 未匹配, while `deepseek-v4-flash-0731` (no such base row) stays unmatched.
|
|
36
|
+
*
|
|
37
|
+
* Every hit also carries `source`: where the answered price came from
|
|
38
|
+
* ('deepseek', 'zen' or 'custom'), so the UI can say which of the three kinds of
|
|
39
|
+
* reference id a row matched.
|
|
40
|
+
*
|
|
41
|
+
* Two vocabularies, and only these two, leave this file:
|
|
42
|
+
* - `how` — which rung of the ladder answered (5 values, for the tooltip);
|
|
43
|
+
* - `state` — what the user sees (4 values: exact / fuzzy / none / custom).
|
|
44
|
+
* `state` is derived HERE so no caller re-derives it: a client that mapped
|
|
45
|
+
* `how` itself once shipped a `'literal'` branch the ladder never emits, and
|
|
46
|
+
* every exact hit read as 未匹配 while the dashboard billed it.
|
|
47
|
+
*
|
|
48
|
+
* One rate shape, too. Whatever a row carries — a flat reference row, a DeepSeek
|
|
49
|
+
* row with `peak`/`offPeak`, or a hand-entered `inputPerM` row — `ratesOf`
|
|
50
|
+
* flattens it to `{ input, output, cacheRead, cacheWrite }` per tier. Nothing
|
|
51
|
+
* else in the codebase reads a price field.
|
|
52
|
+
*/
|
|
53
|
+
import { REFERENCE_PRICES } from './prices.js';
|
|
54
|
+
import { holidayCoverage, holidayOf as holidayOfDefault, isPeak } from './peak.js';
|
|
55
|
+
/** Re-exported so callers keep one import site for "the calendar". */
|
|
56
|
+
export { holidayCoverage, holidayOfDefault };
|
|
57
|
+
/**
|
|
58
|
+
* Aliases the table cannot derive on its own, each with the reason it exists.
|
|
59
|
+
*
|
|
60
|
+
* Only add an entry here when the mapping is a fact about the vendor's naming,
|
|
61
|
+
* not when two ids merely look alike: everything else is handled by the
|
|
62
|
+
* normalized match below.
|
|
63
|
+
*/
|
|
64
|
+
export const ALIASES = {
|
|
65
|
+
// Legacy names the vendor retired but that still appear in older session logs.
|
|
66
|
+
'deepseek-chat': { to: 'deepseek-v4.1-flash', why: '官方 2026-07-24 停用;历史会话按现役 Flash 价估算' },
|
|
67
|
+
'deepseek-reasoner': { to: 'deepseek-v4.1-flash', why: '官方 2026-07-24 停用;历史会话按现役 Flash 价估算' },
|
|
68
|
+
'deepseek-v4-flash': { to: 'deepseek-v4.1-flash', why: '官方已退役,请求被路由到 V4.1 Flash 并按 Flash 价计费' },
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Suffixes that mark a variant of a model the table DOES publish.
|
|
72
|
+
*
|
|
73
|
+
* The list is closed and deterministic on purpose: `-free` / `-exp` (promo and
|
|
74
|
+
* experimental twins), `-fast` (the speed tier several providers resell), and a
|
|
75
|
+
* four-digit publish date (`-1017`, Zen's dated ids). The base id has to be a
|
|
76
|
+
* table row, so a suffix on an unknown model still lands in 未匹配 — this is a
|
|
77
|
+
* rename rule, not a similarity score. Two ids that merely look alike
|
|
78
|
+
* (`deepseek-v4-flash-0731`) never collapse into each other unless the rules
|
|
79
|
+
* below say so and the base exists.
|
|
80
|
+
*/
|
|
81
|
+
const VARIANT_SUFFIX = /-(?:free|exp|fast|latest|\d{4})$/;
|
|
82
|
+
/** Rate field on a price row, and the older spelling a saved config may still use. */
|
|
83
|
+
const RATE_FIELDS = [
|
|
84
|
+
['input', 'inputPerM'],
|
|
85
|
+
['output', 'outputPerM'],
|
|
86
|
+
['cacheRead', 'cacheReadPerM'],
|
|
87
|
+
['cacheWrite', 'cacheWritePerM'],
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Literal normalization: case, provider prefix, separator style, router prefix.
|
|
91
|
+
*
|
|
92
|
+
* The provider prefix is everything up to the LAST '/', because an id may itself
|
|
93
|
+
* contain one (`deepseek/deepseek-v4.1-flash`). Version suffixes are deliberately
|
|
94
|
+
* NOT stripped: `gpt-5.4` and `gpt-5` are different products, and a trailing
|
|
95
|
+
* date (`-1017`) is part of the id Zen publishes.
|
|
96
|
+
*
|
|
97
|
+
* `.` and `-` are unified because the two tables spell one model differently
|
|
98
|
+
* (`deepseek-v4.1-flash` vs `deepseek-v4-1-flash`), and a leading `auto-` is
|
|
99
|
+
* dropped because DSH's router reports `group/auto-<model>`. Both rules are only
|
|
100
|
+
* safe while no two table ids collide into one key, which `price-data.test.js`
|
|
101
|
+
* enforces rather than trusting.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} raw - model id as stored by the harness.
|
|
104
|
+
* @returns {string} the comparable form, never null.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizeId(raw) {
|
|
107
|
+
const text = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
108
|
+
if (text === '')
|
|
109
|
+
return '';
|
|
110
|
+
const bare = text.includes('/') ? text.slice(text.lastIndexOf('/') + 1) : text;
|
|
111
|
+
return bare.replace(/^auto-/, '').replace(/_/g, '-').replace(/\./g, '-');
|
|
112
|
+
}
|
|
113
|
+
/** Lazily built `normalizeId(table id) -> table id` index. */
|
|
114
|
+
let index = null;
|
|
115
|
+
function tableIndex() {
|
|
116
|
+
if (index !== null)
|
|
117
|
+
return index;
|
|
118
|
+
index = new Map();
|
|
119
|
+
for (const id of Object.keys(REFERENCE_PRICES.models)) {
|
|
120
|
+
const key = normalizeId(id);
|
|
121
|
+
// First writer wins: a later duplicate would otherwise shadow the canonical
|
|
122
|
+
// id depending on object order. The collision itself is a test failure.
|
|
123
|
+
if (!index.has(key))
|
|
124
|
+
index.set(key, id);
|
|
125
|
+
}
|
|
126
|
+
return index;
|
|
127
|
+
}
|
|
128
|
+
/** Which tier an instant bills at. Beijing time, weekend and holiday aware. */
|
|
129
|
+
export function tierAt(atMs, holidayOf = holidayOfDefault) {
|
|
130
|
+
return isPeak(atMs, holidayOf) ? 'peak' : 'off';
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Flatten any price row to the four rates one tier bills.
|
|
134
|
+
*
|
|
135
|
+
* This is the only place that knows a price can be shaped three ways. Callers
|
|
136
|
+
* ask for a tier and get numbers or null; a missing field is 0, never NaN.
|
|
137
|
+
*
|
|
138
|
+
* @param {object|null} price - a ladder hit, a hand-entered price, or null.
|
|
139
|
+
* @param {'peak'|'off'} [tier] - which tier of a two-tier row to read.
|
|
140
|
+
* @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number } | null}
|
|
141
|
+
*/
|
|
142
|
+
export function ratesOf(price, tier = 'peak') {
|
|
143
|
+
if (!price || typeof price !== 'object')
|
|
144
|
+
return null;
|
|
145
|
+
let row = price;
|
|
146
|
+
if (price.peak !== undefined || price.offPeak !== undefined) {
|
|
147
|
+
const sub = tier === 'off' ? price.offPeak : price.peak;
|
|
148
|
+
// A tiered row with one side missing still answers: the present tier is the
|
|
149
|
+
// best fact available, and rateOf(0) would invent a price nobody published.
|
|
150
|
+
row = sub !== undefined && sub !== null ? sub : price;
|
|
151
|
+
}
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const [canonical, legacy] of RATE_FIELDS) {
|
|
154
|
+
const v = row[canonical] !== undefined ? row[canonical] : row[legacy];
|
|
155
|
+
out[canonical] = typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve one model id to a price.
|
|
161
|
+
*
|
|
162
|
+
* Everything that can answer "what does this id cost" lives here — including the
|
|
163
|
+
* user's confirmed matches — because a second function wrapping this one is how
|
|
164
|
+
* `how` and the price diverged once already (`propose` reported 未匹配 on models
|
|
165
|
+
* the query billed).
|
|
166
|
+
*
|
|
167
|
+
* @param {object} options - lookup inputs.
|
|
168
|
+
* @param {string} options.model - model id from a usage record.
|
|
169
|
+
* @param {string} [options.provider] - provider id from the same record.
|
|
170
|
+
* @param {Record<string, object>} [options.custom] - the user's explicit price
|
|
171
|
+
* table, keyed by `provider/model` or by bare model id.
|
|
172
|
+
* @param {Record<string, string>} [options.matches] - confirmed matches, same keys,
|
|
173
|
+
* whose value is a reference-table id.
|
|
174
|
+
* @returns {{ price: object|null, how: string, state: string, target: string|null, why: string }}
|
|
175
|
+
* `price` is the row as published (flat, or with both tiers), never a
|
|
176
|
+
* pre-selected tier: the tier belongs to the billing site, which is the only
|
|
177
|
+
* place that knows an instant. `how` is the ladder rung, `state` the
|
|
178
|
+
* four-value verdict the UI shows.
|
|
179
|
+
*/
|
|
180
|
+
export function resolvePrice(options) {
|
|
181
|
+
const model = typeof options?.model === 'string' ? options.model : '';
|
|
182
|
+
const provider = typeof options?.provider === 'string' ? options.provider : '';
|
|
183
|
+
const custom = options?.custom && typeof options.custom === 'object' ? options.custom : null;
|
|
184
|
+
const matches = options?.matches && typeof options.matches === 'object' ? options.matches : null;
|
|
185
|
+
const composite = provider === '' ? '' : provider + '/' + model;
|
|
186
|
+
/** One table row, as published. */
|
|
187
|
+
const rowOf = (id) => (Object.prototype.hasOwnProperty.call(REFERENCE_PRICES.models, id) ? REFERENCE_PRICES.models[id] : null);
|
|
188
|
+
// 1. The user's own price always wins, composite key before bare id.
|
|
189
|
+
if (custom !== null) {
|
|
190
|
+
const own = (composite !== '' && custom[composite]) || custom[model];
|
|
191
|
+
if (own && typeof own === 'object') {
|
|
192
|
+
return { price: own, how: 'custom', state: 'custom', target: null, why: '用户自定义单价', source: 'custom' };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 2. A match the user confirmed. It outranks the automatic ladder — it is a
|
|
196
|
+
// fact the user established — but never the explicit price above. A target
|
|
197
|
+
// may be one of the user's own rows as well as a table row, because a
|
|
198
|
+
// "custom" reference is one of the three kinds of reference id.
|
|
199
|
+
if (matches !== null) {
|
|
200
|
+
const confirmed = (composite !== '' && matches[composite]) || matches[model];
|
|
201
|
+
if (typeof confirmed === 'string' && confirmed !== '') {
|
|
202
|
+
const own = custom !== null && Object.prototype.hasOwnProperty.call(custom, confirmed) ? custom[confirmed] : null;
|
|
203
|
+
if (own !== null && typeof own === 'object') {
|
|
204
|
+
return { price: own, how: 'confirmed', state: 'custom', target: confirmed, why: '用户确认的匹配(自定义价)', source: 'custom' };
|
|
205
|
+
}
|
|
206
|
+
const price = rowOf(confirmed);
|
|
207
|
+
if (price !== null) {
|
|
208
|
+
return { price, how: 'confirmed', state: 'custom', target: confirmed, why: '用户确认的匹配', source: price.source };
|
|
209
|
+
}
|
|
210
|
+
// The confirmed target left the table. Say so instead of falling through
|
|
211
|
+
// silently: a stale confirmation is a fact the user should see, not a
|
|
212
|
+
// price that quietly changed.
|
|
213
|
+
return { price: null, how: 'unmatched', state: 'none', target: confirmed, why: '已确认的参考模型不在当前价目表中', source: null };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const bare = normalizeId(model);
|
|
217
|
+
// 3. A normalized literal match. This runs before the two inferred rungs: an
|
|
218
|
+
// id the table really publishes is a fact, and an inference may not
|
|
219
|
+
// override it. It is also the `-free` rule — a published `-free` row is
|
|
220
|
+
// exact and keeps its own price (0 during a promotion); a `-free` id the
|
|
221
|
+
// table does not carry is what the twin rung exists for.
|
|
222
|
+
const direct = tableIndex().get(bare);
|
|
223
|
+
if (direct !== undefined) {
|
|
224
|
+
const row = rowOf(direct);
|
|
225
|
+
return { price: row, how: 'normalized', state: 'exact', target: direct, why: '与价目表 id 字面一致', source: row.source };
|
|
226
|
+
}
|
|
227
|
+
// 4. The alias table.
|
|
228
|
+
const alias = ALIASES[bare] ?? ALIASES[model];
|
|
229
|
+
if (alias !== undefined) {
|
|
230
|
+
const price = rowOf(alias.to);
|
|
231
|
+
if (price !== null) {
|
|
232
|
+
return { price, how: 'alias', state: 'fuzzy', target: alias.to, why: alias.why, source: price.source };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 5. A variant of a model the table publishes, under a name the table does not:
|
|
236
|
+
// `deepseek-v4.1-flash-fast`, `deepseek-v4.1-flash-1017`, or a `-free` /
|
|
237
|
+
// `-exp` twin. Bill it at the base row and report it as 模糊 — the id is
|
|
238
|
+
// inferred, not literal, so the user can still see and override it.
|
|
239
|
+
//
|
|
240
|
+
// The base must exist in the table; otherwise this rung does nothing and the
|
|
241
|
+
// id stays 未匹配 (no guessing). A published `-free` row never reaches here:
|
|
242
|
+
// the literal rung above already answered it.
|
|
243
|
+
const variantBase = bare.replace(VARIANT_SUFFIX, '');
|
|
244
|
+
if (variantBase !== bare) {
|
|
245
|
+
const baseId = tableIndex().get(variantBase);
|
|
246
|
+
if (baseId !== undefined) {
|
|
247
|
+
const baseRow = rowOf(baseId);
|
|
248
|
+
return {
|
|
249
|
+
price: baseRow,
|
|
250
|
+
how: 'variant',
|
|
251
|
+
state: 'fuzzy',
|
|
252
|
+
target: baseId,
|
|
253
|
+
why: '同一模型的变体名(' + bare.slice(variantBase.length) + ' 后缀),按 ' + baseId + ' 的价估算',
|
|
254
|
+
source: baseRow.source,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { price: null, how: 'unmatched', state: 'none', target: null, why: '价目表里没有可对应的模型', source: null };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Reference cost of one folded record.
|
|
262
|
+
*
|
|
263
|
+
* The record supplies either `tokens` buckets (peak / off, computed while
|
|
264
|
+
* folding, which is the only place an event's own timestamp is available) or a
|
|
265
|
+
* flat token set, in which case the record's own instant picks the tier.
|
|
266
|
+
*
|
|
267
|
+
* @param {{ inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number,
|
|
268
|
+
* tokens?: { peak?: object, off?: object }, time?: number }} record - a usage record.
|
|
269
|
+
* @param {object} price - any shape `ratesOf` accepts, USD per 1M tokens.
|
|
270
|
+
* @returns {number} USD.
|
|
271
|
+
*/
|
|
272
|
+
export function costOf(record, price) {
|
|
273
|
+
if (!record || !price)
|
|
274
|
+
return 0;
|
|
275
|
+
const bill = (bucket, tier) => {
|
|
276
|
+
const rate = ratesOf(price, tier);
|
|
277
|
+
if (rate === null)
|
|
278
|
+
return 0;
|
|
279
|
+
return (((bucket && bucket.inputTokens) || 0) * rate.input
|
|
280
|
+
+ ((bucket && bucket.outputTokens) || 0) * rate.output
|
|
281
|
+
+ ((bucket && bucket.cacheReadTokens) || 0) * rate.cacheRead
|
|
282
|
+
+ ((bucket && bucket.cacheWriteTokens) || 0) * rate.cacheWrite) / 1e6;
|
|
283
|
+
};
|
|
284
|
+
const buckets = record.tokens;
|
|
285
|
+
if (buckets && (buckets.peak || buckets.off))
|
|
286
|
+
return bill(buckets.peak, 'peak') + bill(buckets.off, 'off');
|
|
287
|
+
return bill(record, tierAt(typeof record.time === 'number' ? record.time : Date.now()));
|
|
288
|
+
}
|
|
289
|
+
/** The table, for the settings dialog's model picker. */
|
|
290
|
+
export function referenceTable() {
|
|
291
|
+
return REFERENCE_PRICES;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The wire shape of one priced model, shared by `query` and `propose`.
|
|
295
|
+
*
|
|
296
|
+
* Both endpoints describing the same model must describe it identically; the
|
|
297
|
+
* one time they did not, the dialog showed 未匹配 on rows the dashboard billed.
|
|
298
|
+
* `rates` carries BOTH tiers so the client never has to guess a tier itself —
|
|
299
|
+
* it prints `rates[tier]`, where `tier` is the host's own answer for "now".
|
|
300
|
+
*
|
|
301
|
+
* `source` names the kind of reference id that answered: 'deepseek', 'zen' or
|
|
302
|
+
* 'custom'. Those are the three kinds the UI groups by, so it travels with the
|
|
303
|
+
* row instead of being looked up again on the other side.
|
|
304
|
+
*
|
|
305
|
+
* @param {{ price: object|null, how: string, state: string, target: string|null, why: string,
|
|
306
|
+
* source?: string|null }} hit - ladder output.
|
|
307
|
+
* @param {number} [atMs] - instant whose tier the UI labels the price with.
|
|
308
|
+
* @param {(date: string) => boolean} [holidayOf] - holiday test, for tests.
|
|
309
|
+
* @returns {{ state: string, how: string, target: string|null, why: string,
|
|
310
|
+
* source: string|null, tier: 'peak'|'off', rates: { peak: object, offPeak: object } | null }}
|
|
311
|
+
*/
|
|
312
|
+
export function priceRow(hit, atMs = Date.now(), holidayOf = holidayOfDefault) {
|
|
313
|
+
return {
|
|
314
|
+
state: hit.state,
|
|
315
|
+
how: hit.how,
|
|
316
|
+
target: hit.target === undefined ? null : hit.target,
|
|
317
|
+
why: hit.why === undefined ? null : hit.why,
|
|
318
|
+
source: hit.source === undefined ? null : hit.source,
|
|
319
|
+
tier: tierAt(atMs, holidayOf),
|
|
320
|
+
rates: hit.price === null
|
|
321
|
+
? null
|
|
322
|
+
: { peak: ratesOf(hit.price, 'peak'), offPeak: ratesOf(hit.price, 'off') },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The two generated reference tables, grouped, with the source each id came from.
|
|
327
|
+
*
|
|
328
|
+
* 'custom' is deliberately NOT a group here: in the dialog it is a single option
|
|
329
|
+
* ("type your own price"), not a list of ids — a hand-entered price is three
|
|
330
|
+
* numbers for one model key, not a reference model anyone could point another id
|
|
331
|
+
* at. DeepSeek first, then Zen.
|
|
332
|
+
*
|
|
333
|
+
* @returns {{ source: string, ids: string[] }[]} two groups, each stable-sorted.
|
|
334
|
+
*/
|
|
335
|
+
export function referenceGroups() {
|
|
336
|
+
const groups = [
|
|
337
|
+
{ source: 'deepseek', ids: [] },
|
|
338
|
+
{ source: 'zen', ids: [] },
|
|
339
|
+
];
|
|
340
|
+
const bySource = new Map(groups.map((g) => [g.source, g]));
|
|
341
|
+
for (const [id, entry] of Object.entries(REFERENCE_PRICES.models)) {
|
|
342
|
+
const group = bySource.get(entry && entry.source);
|
|
343
|
+
if (group !== undefined)
|
|
344
|
+
group.ids.push(id);
|
|
345
|
+
}
|
|
346
|
+
for (const group of groups)
|
|
347
|
+
group.ids.sort();
|
|
348
|
+
return groups;
|
|
349
|
+
}
|
package/lib/host/query.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js';
|
|
8
8
|
import { currencyOf } from './settings.js';
|
|
9
|
+
import { holidayCoverage, priceRow, referenceTable } from './pricing.js';
|
|
9
10
|
function emptyBucket(key) {
|
|
10
11
|
return {
|
|
11
12
|
key,
|
|
@@ -32,6 +33,28 @@ function addToBucket(b, r, cost) {
|
|
|
32
33
|
function compositeModelKey(r) {
|
|
33
34
|
return r.provider ? r.provider + '/' + r.model : r.model;
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Where the numbers came from, so the dashboard can say it out loud.
|
|
38
|
+
*
|
|
39
|
+
* A reference cost is an estimate against a published list, not a bill: the card
|
|
40
|
+
* has to name both sources and the day they were read, or a user comparing it to
|
|
41
|
+
* an invoice has no way to tell a wrong number from a different scope.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} state - runtime state capsule.
|
|
44
|
+
* @returns {{ source: string, capturedAt: string|null, note: string, holidays: object }}
|
|
45
|
+
*/
|
|
46
|
+
function pricingMeta(state) {
|
|
47
|
+
const table = referenceTable();
|
|
48
|
+
const names = Array.isArray(table.sources) ? table.sources.map((s) => s && s.name).filter(Boolean) : [];
|
|
49
|
+
return {
|
|
50
|
+
// Named from the table itself: a hard-coded string here is a second source
|
|
51
|
+
// of truth that keeps claiming a source the generator no longer reads.
|
|
52
|
+
source: names.length > 0 ? names.join(' + ') : '—',
|
|
53
|
+
capturedAt: typeof table.capturedAt === 'string' ? table.capturedAt : null,
|
|
54
|
+
note: '按 opencode Zen 和 DeepSeek 最新定价口径估算',
|
|
55
|
+
holidays: holidayCoverage(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
35
58
|
export function query(state, args) {
|
|
36
59
|
const from = typeof args.from === 'number' ? args.from : -Infinity;
|
|
37
60
|
const to = typeof args.to === 'number' ? args.to : Infinity;
|
|
@@ -55,6 +78,11 @@ export function query(state, args) {
|
|
|
55
78
|
const total = emptyBucket('total');
|
|
56
79
|
const seenModels = new Map(); // composite key -> {key, provider, model} (first composite wins)
|
|
57
80
|
let unpricedCalls = 0;
|
|
81
|
+
// Models that resolved to no price at all, and how the rest were priced. The
|
|
82
|
+
// dialog reads the first list; the card note reads the second (a reference
|
|
83
|
+
// price and a hand-entered one are not the same claim).
|
|
84
|
+
const unmatched = new Map(); // composite key -> { key, provider, model, calls, totalTokens }
|
|
85
|
+
const resolution = new Map(); // composite key -> priceRow()
|
|
58
86
|
// Range-anchored day-step grid: blocks start at the range's first day.
|
|
59
87
|
const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
|
|
60
88
|
const useStepGrid = granularity === 'day' && stepDays > 1 && dayAnchor !== null;
|
|
@@ -69,9 +97,21 @@ export function query(state, args) {
|
|
|
69
97
|
seenModels.set(modelKey, { key: modelKey, provider: r.provider, model: r.model });
|
|
70
98
|
if (!modelMatches(r))
|
|
71
99
|
continue;
|
|
72
|
-
const
|
|
73
|
-
|
|
100
|
+
const hit = state.prices.lookup(r);
|
|
101
|
+
const cost = hit.price === null ? 0 : state.prices.costOf(r);
|
|
102
|
+
if (r.totalTokens > 0 && hit.price === null) {
|
|
74
103
|
unpricedCalls += 1;
|
|
104
|
+
const seen = unmatched.get(modelKey);
|
|
105
|
+
if (seen === undefined) {
|
|
106
|
+
unmatched.set(modelKey, { key: modelKey, provider: r.provider, model: r.model, calls: 1, totalTokens: r.totalTokens });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
seen.calls += 1;
|
|
110
|
+
seen.totalTokens += r.totalTokens;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!resolution.has(modelKey))
|
|
114
|
+
resolution.set(modelKey, priceRow(hit));
|
|
75
115
|
const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
|
|
76
116
|
let db = dailyMap.get(dk);
|
|
77
117
|
if (db === undefined) {
|
|
@@ -164,14 +204,32 @@ export function query(state, args) {
|
|
|
164
204
|
if (heatmap !== null)
|
|
165
205
|
for (const b of heatmap)
|
|
166
206
|
b.cost = toDisplay(b.cost);
|
|
207
|
+
// Per-model verdict, straight from the ladder's own row shape (`priceRow`), so
|
|
208
|
+
// a model the dashboard bills and the dialog shows carry the same four-value
|
|
209
|
+
// `state` — the dashboard does not re-derive one from `how` any more.
|
|
210
|
+
for (const row of byModel) {
|
|
211
|
+
const known = resolution.get(row.key);
|
|
212
|
+
if (known !== undefined)
|
|
213
|
+
Object.assign(row, known);
|
|
214
|
+
if (unmatched.has(row.key))
|
|
215
|
+
row.unpriced = true;
|
|
216
|
+
}
|
|
167
217
|
return {
|
|
168
218
|
daily,
|
|
169
219
|
byModel,
|
|
170
220
|
total,
|
|
171
221
|
models,
|
|
172
222
|
unpricedCalls,
|
|
223
|
+
unmatched: [...unmatched.values()],
|
|
224
|
+
pricing: pricingMeta(state),
|
|
173
225
|
currency: { code: money.code, symbol: money.symbol },
|
|
226
|
+
// Which client→host channel this process actually opened, and why — the
|
|
227
|
+
// plugin logger is not always wired into the harness log, so the decision
|
|
228
|
+
// rides along with the data the dashboard already asks for.
|
|
229
|
+
channel: state.channel ?? null,
|
|
174
230
|
...heatmap === null ? {} : { heatmap },
|
|
175
231
|
backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done },
|
|
232
|
+
// Diagnostics for "why was the first load slow": one object per scan pass.
|
|
233
|
+
scan: state.lastPass || null,
|
|
176
234
|
};
|
|
177
235
|
}
|
package/lib/host/rpc.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* response body = `{ ok: true, value }` | `{ ok: false, error: { code, message } }`
|
|
37
37
|
* i.e. the same result shape the legacy RPC channel wraps, minus its envelope.
|
|
38
38
|
*/
|
|
39
|
+
import { registerTypert } from './typert.js';
|
|
39
40
|
/** Absolute logical channel owned by this plugin on pre-0.2 hosts. */
|
|
40
41
|
export const USAGE_RPC_CHANNEL = '/usage';
|
|
41
42
|
/** Shared-channel path prefix owned by this plugin (`/api` is Connection's mount). */
|
|
@@ -52,6 +53,9 @@ export function fetchRoutePath(endpoint) {
|
|
|
52
53
|
/**
|
|
53
54
|
* Failure result in the Connection RPC result shape.
|
|
54
55
|
*
|
|
56
|
+
* The message here is for the CALLER and must be safe to render: raw error text
|
|
57
|
+
* belongs in the host log (see `dispatch`), never on the page.
|
|
58
|
+
*
|
|
55
59
|
* @param {string} message - human-readable failure.
|
|
56
60
|
* @param {string} [code] - stable error code.
|
|
57
61
|
* @returns {{ ok: false, error: { code: string, message: string, details: object } }} failure result.
|
|
@@ -59,15 +63,53 @@ export function fetchRoutePath(endpoint) {
|
|
|
59
63
|
export function errorResult(message, code = 'internal') {
|
|
60
64
|
return { ok: false, error: { code, message, details: {} } };
|
|
61
65
|
}
|
|
66
|
+
/** What the page is told when a handler throws: the detail goes to the log. */
|
|
67
|
+
export const INTERNAL_MESSAGE = 'internal error';
|
|
68
|
+
/**
|
|
69
|
+
* Log sink for one host context.
|
|
70
|
+
*
|
|
71
|
+
* `ctx.logger` is the native channel (official plugins log through it); the
|
|
72
|
+
* console is only a fallback for a composition that has no logger. Wrapped
|
|
73
|
+
* because ctx is a strict proxy and a bare `ctx.logger` can throw.
|
|
74
|
+
*
|
|
75
|
+
* @param {any} ctx - host plugin context.
|
|
76
|
+
* @returns {(message: string, level?: 'warn' | 'error') => void} sink.
|
|
77
|
+
*/
|
|
78
|
+
export function logSink(ctx) {
|
|
79
|
+
let logger = null;
|
|
80
|
+
try {
|
|
81
|
+
logger = ctx && typeof ctx.get === 'function' ? ctx.get('logger') : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
logger = null;
|
|
85
|
+
}
|
|
86
|
+
if (!logger) {
|
|
87
|
+
try {
|
|
88
|
+
if (ctx && ctx.logger && typeof ctx.logger.error === 'function')
|
|
89
|
+
logger = ctx.logger;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
logger = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (logger && typeof logger.error === 'function') {
|
|
96
|
+
return (message, level = 'warn') => {
|
|
97
|
+
const fn = level === 'error' ? logger.error : (typeof logger.warn === 'function' ? logger.warn : logger.error);
|
|
98
|
+
fn.call(logger, message);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return (message, level = 'warn') => (level === 'error' ? console.error(message) : console.warn(message));
|
|
102
|
+
}
|
|
62
103
|
/**
|
|
63
104
|
* Run one endpoint through the handler map, converting throws into results.
|
|
64
105
|
*
|
|
65
106
|
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
66
107
|
* @param {string} endpoint - endpoint name.
|
|
67
108
|
* @param {unknown} args - decoded endpoint arguments.
|
|
109
|
+
* @param {(message: string, level?: 'warn' | 'error') => void} [log] - host log sink for the raw error.
|
|
68
110
|
* @returns {Promise<{ ok: true, value: any } | { ok: false, error: object }>} RPC result.
|
|
69
111
|
*/
|
|
70
|
-
export async function dispatch(handlers, endpoint, args) {
|
|
112
|
+
export async function dispatch(handlers, endpoint, args, log) {
|
|
71
113
|
const fn = handlers.get(endpoint);
|
|
72
114
|
if (fn === undefined)
|
|
73
115
|
return errorResult(`unknown endpoint: ${endpoint}`, 'bad-request');
|
|
@@ -75,7 +117,10 @@ export async function dispatch(handlers, endpoint, args) {
|
|
|
75
117
|
return { ok: true, value: await fn(args) };
|
|
76
118
|
}
|
|
77
119
|
catch (error) {
|
|
78
|
-
|
|
120
|
+
const detail = String((error && error.stack) || (error && error.message) || error);
|
|
121
|
+
if (typeof log === 'function')
|
|
122
|
+
log(`[usage] ${endpoint} failed: ${detail}`, 'error');
|
|
123
|
+
return errorResult(INTERNAL_MESSAGE, 'internal');
|
|
79
124
|
}
|
|
80
125
|
}
|
|
81
126
|
/**
|
|
@@ -83,9 +128,10 @@ export async function dispatch(handlers, endpoint, args) {
|
|
|
83
128
|
*
|
|
84
129
|
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
85
130
|
* @param {string} endpoint - endpoint name.
|
|
131
|
+
* @param {(message: string, level?: 'warn' | 'error') => void} [log] - host log sink.
|
|
86
132
|
* @returns {(request: Request) => Promise<Response>} route handler.
|
|
87
133
|
*/
|
|
88
|
-
export function createFetchRoute(handlers, endpoint) {
|
|
134
|
+
export function createFetchRoute(handlers, endpoint, log) {
|
|
89
135
|
return async function fetchRoute(request) {
|
|
90
136
|
let args = null;
|
|
91
137
|
const text = await request.text();
|
|
@@ -97,7 +143,7 @@ export function createFetchRoute(handlers, endpoint) {
|
|
|
97
143
|
return Response.json(errorResult('request body is not JSON', 'bad-request'), { status: 400 });
|
|
98
144
|
}
|
|
99
145
|
}
|
|
100
|
-
return Response.json(await dispatch(handlers, endpoint, args));
|
|
146
|
+
return Response.json(await dispatch(handlers, endpoint, args, log));
|
|
101
147
|
};
|
|
102
148
|
}
|
|
103
149
|
/**
|
|
@@ -114,35 +160,21 @@ export function registerRPC(ctx, handlers) {
|
|
|
114
160
|
}
|
|
115
161
|
return;
|
|
116
162
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// Read the gateway only via `ctx.get('typertGateway')` — cordis ctx is a
|
|
121
|
-
// property). The installed v0.1.0-rc.7 `TypertGateway` interface exposes
|
|
122
|
-
// only invoke() (see the package's lib/types/types.d.ts); the documented
|
|
123
|
-
// contribution path (@Remote/@RemoteScope + TypertRemoteService or
|
|
124
|
-
// bindTypertRemote from @deepseek-ai/dsh-typert-protocol) needs a runtime
|
|
125
|
-
// dependency this plugin must not add, so we only claim endpoints through a
|
|
126
|
-
// registration surface that already exists on the detected object:
|
|
127
|
-
// `typert.register(channel, endpoints)`, a forward-compatible contribution
|
|
128
|
-
// hook for host compositions that expose the gateway with registration.
|
|
163
|
+
// Native Remote channel (see ./typert.js for the contract and its evidence).
|
|
164
|
+
// Registered in addition to the Fetch routes below, not instead of them: the
|
|
165
|
+
// browser half prefers Remote and falls back to Fetch, so both must be real.
|
|
129
166
|
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
endpoints[endpoint] = fn;
|
|
142
|
-
}
|
|
143
|
-
typert.register('usage', endpoints);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
167
|
+
// ⚠️ Do not probe `ctx.get('typert')` at activation time: cordis `get` returns
|
|
168
|
+
// only services whose providing fiber is ACTIVE (`_getImpl` checks
|
|
169
|
+
// `impl.fiber.state === 2`), and sibling bundles in this composition can
|
|
170
|
+
// activate after us. A one-shot probe reports "no typert registry" on a host
|
|
171
|
+
// that has one. Declare the dependency instead — the same pattern used for
|
|
172
|
+
// `connection` below — and record the decision when it lands.
|
|
173
|
+
const log = logSink(ctx);
|
|
174
|
+
const channel = { ok: false, transport: 'fetch', reason: 'typert not active yet (waiting for the dependency)' };
|
|
175
|
+
ctx.inject(['typert'], (typertCtx) => {
|
|
176
|
+
Object.assign(channel, registerTypert(typertCtx, handlers, log));
|
|
177
|
+
});
|
|
146
178
|
// Static (bundle) installs: the `connection` service is provided
|
|
147
179
|
// asynchronously by the client-connection plugin, so wait for it before
|
|
148
180
|
// registering (the official api-gateway pattern).
|
|
@@ -163,12 +195,12 @@ export function registerRPC(ctx, handlers) {
|
|
|
163
195
|
path,
|
|
164
196
|
methods: ['POST'],
|
|
165
197
|
requestBody: 'buffered',
|
|
166
|
-
fetch: createFetchRoute(handlers, endpoint),
|
|
198
|
+
fetch: createFetchRoute(handlers, endpoint, log),
|
|
167
199
|
});
|
|
168
200
|
mountedFetchRoutes += 1;
|
|
169
201
|
}
|
|
170
202
|
catch (error) {
|
|
171
|
-
|
|
203
|
+
log(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`);
|
|
172
204
|
}
|
|
173
205
|
}
|
|
174
206
|
}
|
|
@@ -179,13 +211,14 @@ export function registerRPC(ctx, handlers) {
|
|
|
179
211
|
const rpcRegistry = connection.rpc;
|
|
180
212
|
if (rpcRegistry !== undefined && typeof rpcRegistry.handle === 'function') {
|
|
181
213
|
try {
|
|
182
|
-
rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args), { authority: 'loopback' });
|
|
214
|
+
rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args, log), { authority: 'loopback' });
|
|
183
215
|
}
|
|
184
216
|
catch (error) {
|
|
185
217
|
if (mountedFetchRoutes === 0) {
|
|
186
|
-
|
|
218
|
+
log(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`);
|
|
187
219
|
}
|
|
188
220
|
}
|
|
189
221
|
}
|
|
190
222
|
});
|
|
223
|
+
return channel;
|
|
191
224
|
}
|