@ebony-vinyl/dsh-our-free-model 2.7.4 → 2.8.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.
@@ -1,456 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { FreeModelAdapter, ROUTE_MAIN, ROUTE_REGION } from '../adapter.js'
4
- import { buildCatalog, buildEacCatalog, buildKiloCatalog, isEacEntry, isKiloEntry, parseListing, reviveKiloCatalog } from '../catalog.js'
5
- import { buildAggregateCatalog, isAggregateEntry } from '../aggregate.js'
6
- import { buildComboCatalog, isComboEntry, normalizeCombos, pruneRotation } from '../combos.js'
7
- import { JsonStore, SETTINGS_INITIAL, STATS_INITIAL, STATS_VERSION, migrateStats, pruneDays, recordTurn, recordUsage, isModelHidden } from '../store.js'
8
- import { STATE, detectEgress, probeCatalog } from '../probe.js'
9
- import { CODE, UpstreamError, getJson } from '../http.js'
10
- import { runModelTest, modelAvailability } from '../model-test.js'
11
- import { DEFAULT_LEVEL } from '../effort.js'
12
- import { fetchSealedListing } from '../eac.js'
13
- import { fetchKiloListing } from '../kilo.js'
14
- import { sessionForConversation } from '../upstream.js'
15
- import { createForwardCompletion, publicModelRows } from './completion.js'
16
- import { isSubscriptionEntry, subscriptionCatalog } from '../subscription-adapter.js'
17
-
18
- const FALLBACK_CATALOG = buildCatalog([
19
- 'mimo-v2.6-flash-free', 'mimo-v2.5-free', 'ling-3.0-flash-fin-free',
20
- 'nemotron-3-ultra-free', 'nemotron-3.5-lightning-free', 'space-bunny-free',
21
- 'muse-spark-1.3-contributor-free', 'muse-spark-1.2-contributor-free',
22
- ])
23
-
24
- /** 路径由产品入口决定;核心不解析 DSH_HOME,也不发现宿主服务。 */
25
- export function createRuntimeStores({ dataDir, logger = console }) {
26
- if (typeof dataDir !== 'string' || !path.isAbsolute(dataDir)) {
27
- throw new TypeError('dataDir must be an absolute path')
28
- }
29
- fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 })
30
- const open = (file, initial) => new JsonStore(path.join(dataDir, file), structuredClone(initial), {
31
- log: message => logger.warn?.(message),
32
- })
33
- // Seed the recommended combo only for a genuinely fresh install. Existing
34
- // settings files that predate combos must not gain a new advertised model
35
- // merely because a new default was added to SETTINGS_INITIAL; user edits to
36
- // an existing `combos` field still win through JsonStore's normal merge.
37
- const settingsInitial = structuredClone(SETTINGS_INITIAL)
38
- const settingsPath = path.join(dataDir, 'settings.json')
39
- if (fs.existsSync(settingsPath)) settingsInitial.combos = []
40
- const settings = new JsonStore(settingsPath, settingsInitial, {
41
- log: message => logger.warn?.(message),
42
- })
43
- const stats = open('stats.json', STATS_INITIAL)
44
- const availability = open('availability.json', { version: 1, at: 0, egress: null, results: {} })
45
- const catalogStore = open('catalog.json', { version: 1, at: 0, entries: FALLBACK_CATALOG.map(entry => entry.id) })
46
- if (stats.get().version !== STATS_VERSION) stats.edit(migrateStats)
47
- return {
48
- settings, stats, availability, catalogStore,
49
- dispose() {
50
- settings.dispose(); stats.dispose(); availability.dispose(); catalogStore.dispose()
51
- },
52
- }
53
- }
54
-
55
- /**
56
- * 两个产品共同使用的模型运行时。宿主仅注入能力,不传入 Cordis ctx。
57
- * 创建不启动网络任务;入口先绑定本地监听器,再决定何时刷新和探测。
58
- * Optional subscriptionRuntime supplies catalog() and complete(request, onChunk);
59
- * its helper owns accounting. Call syncAggregateCatalog() after account mutations.
60
- */
61
- export function createModelRuntime({
62
- settings, stats, availability, catalogStore,
63
- logger = console,
64
- attributionUserAgent = 'our-free-model',
65
- resolveImage,
66
- sealedCredential = () => null,
67
- onSealedUnavailable = () => {},
68
- onTopology = () => {},
69
- subscriptionRuntime,
70
- }) {
71
- let catalog = materializeCatalog(catalogStore.get().entries ?? [])
72
- let sealedCatalog = sealedCredential() === null ? [] : buildEacCatalog(catalogStore.get().sealIds ?? [])
73
- let kiloCatalog = reviveKiloCatalog(catalogStore.get().kiloRows)
74
- /** The aggregate lane: one row per enabled provider/model the user configured.
75
- * Derived from settings, not from a listing, so it is rebuilt on every merge. */
76
- let aggregateCatalog = []
77
- let subscriptionEntries = []
78
- /** The virtual rows a combo contributes; rebuilt last, after every real lane. */
79
- let comboCatalog = []
80
- /** The free lane's own roster: the base every other lane is merged onto. */
81
- let freeCatalog = catalog
82
- let egress = availability.get().egress ?? null
83
- let gatewayLatency = { ms: 0, at: 0 }
84
- let disposed = false
85
- const lifetime = new AbortController()
86
- let egressGeneration = 0
87
-
88
- /**
89
- * Rebuild the merged catalog from its parts, in precedence order.
90
- *
91
- * Rebuilt rather than appended to, because every one of these lists can shrink:
92
- * a provider the user switched off, a chain whose head was removed, a Kilo
93
- * model that went paid. Appending only ever grew the view, so a removed row
94
- * stayed dialable until the next full listing round happened to reset it.
95
- *
96
- * Combos come last on purpose — a chain's links have to already exist as real
97
- * rows, which is also what makes `buildComboCatalog` drop a link that is gone
98
- * instead of keeping a hopeful placeholder.
99
- */
100
- const rebuildCatalog = () => {
101
- const seen = new Set()
102
- const merged = []
103
- for (const entry of [...freeCatalog, ...sealedCatalog, ...kiloCatalog, ...aggregateCatalog, ...subscriptionEntries]) {
104
- if (entry === null || entry === undefined || seen.has(entry.id)) continue
105
- seen.add(entry.id)
106
- merged.push(entry)
107
- }
108
- const combos = normalizeCombos(settings.get().combos)
109
- comboCatalog = buildComboCatalog(combos, merged)
110
- pruneRotation(combos)
111
- for (const entry of comboCatalog) {
112
- if (seen.has(entry.id)) continue
113
- seen.add(entry.id)
114
- merged.push(entry)
115
- }
116
- catalog = merged
117
- }
118
- const mergeCatalogs = () => {
119
- aggregateCatalog = buildAggregateCatalog(settings.get().aggregate ?? {})
120
- subscriptionEntries = subscriptionCatalog(subscriptionRuntime)
121
- rebuildCatalog()
122
- }
123
- mergeCatalogs()
124
- const state = () => ({
125
- catalog,
126
- membership: computeMembership(catalog, availability.get(), settings.get()),
127
- settings: settings.get(),
128
- attributionUserAgent,
129
- routing: {
130
- quota: subscriptionRuntime?.routingQuota?.() ?? {},
131
- usage: Object.fromEntries(catalog.map(row => [row.id, stats.get().models?.[row.id]?.calls ?? 0])),
132
- },
133
- })
134
- const emitTopology = () => { if (!disposed) onTopology() }
135
- const adapter = new FreeModelAdapter({
136
- state,
137
- resolveImage,
138
- subscriptionRuntime,
139
- sealedCredential,
140
- recordUsage: record => {
141
- if (disposed) return
142
- recordUsage(stats, record)
143
- stats.edit(value => pruneDays(value, 120))
144
- },
145
- recordTurn: record => { if (!disposed) recordTurn(stats, record) },
146
- warn: message => logger.warn?.(message) ?? logger.log?.(message),
147
- // Automatic probing is disabled by owner policy: a region refusal from a
148
- // real turn stays a turn failure and never fires a background probe.
149
- })
150
-
151
- // 手动强制刷新不能借用一个因限流而跳过探测的普通刷新。
152
- let catalogRefresh = null
153
- let catalogRefreshForced = false
154
- let catalogRefreshProbed = false
155
- async function refreshCatalog(opts) {
156
- // Automatic probing is disabled by owner policy: `probe` defaults to false
157
- // and every automatic caller passes false. Only an explicit user action (the
158
- // reprobe button or an explicit caller) asks for `probe: true`.
159
- const { probe = false, force = false, report = false } = opts ?? {}
160
- // 管理接口按需取本轮结果,其他调用仍返回模型数组。
161
- const result = value => report ? value : value.catalog
162
- if (disposed) return result({ catalog, sources: {} })
163
- while (catalogRefresh !== null) {
164
- const shared = catalogRefresh
165
- const sharedForced = catalogRefreshForced
166
- const sharedProbed = catalogRefreshProbed
167
- const value = await shared
168
- if (disposed || ((!force || sharedForced) && (!probe || sharedProbed))) return result(value)
169
- }
170
- const run = refreshCatalogOnce({ probe, force })
171
- catalogRefresh = run
172
- catalogRefreshForced = force
173
- catalogRefreshProbed = probe
174
- try { return result(await run) } finally {
175
- if (catalogRefresh === run) {
176
- catalogRefresh = null; catalogRefreshForced = false; catalogRefreshProbed = false
177
- }
178
- }
179
- }
180
-
181
- async function refreshCatalogOnce({ probe, force }) {
182
- const sources = {}
183
- let ids = []
184
- try {
185
- ids = parseListing(await fetchListing())
186
- } catch (error) {
187
- if (!disposed) logger.warn?.(`our-free-model: model listing refresh failed (${error?.message ?? error}); keeping the cached catalog`)
188
- }
189
- if (disposed) return { catalog, sources }
190
- if (ids.length > 0) {
191
- freeCatalog = buildCatalog(ids)
192
- catalogStore.update({ at: Date.now(), entries: freeCatalog.map(entry => entry.id) })
193
- catalogStore.flush()
194
- settings.update({ catalogSyncedAt: Date.now() })
195
- } else {
196
- freeCatalog = materializeCatalog(catalogStore.get().entries ?? [])
197
- }
198
- sources.free = { ok: ids.length > 0, count: freeCatalog.length }
199
- sources.sealed = await refreshSealedRoster()
200
- if (disposed) return { catalog, sources }
201
- sources.kilo = { ok: await refreshKiloRoster(), count: kiloCatalog.length }
202
- if (disposed) return { catalog, sources }
203
- mergeCatalogs()
204
- if (probe) await refreshAvailability(force)
205
- emitTopology()
206
- return { catalog, sources }
207
- }
208
-
209
- async function refreshSealedRoster() {
210
- const credential = sealedCredential()
211
- if (credential === null) {
212
- sealedCatalog = []
213
- onSealedUnavailable()
214
- return { ok: true, count: 0, skipped: true }
215
- }
216
- try {
217
- const ids = parseListing(await fetchSealedListing(credential, { signal: lifetime.signal }))
218
- if (disposed) return
219
- // 独立应用授权变更时,旧账号正在返回的清单不得覆盖新状态。
220
- if (Object.hasOwn(credential, 'userToken') && sealedCredential()?.userToken !== credential.userToken) return { ok: false, count: sealedCatalog.length, skipped: true }
221
- if (ids.length > 0) {
222
- sealedCatalog = buildEacCatalog(ids)
223
- catalogStore.update({ sealIds: sealedCatalog.map(entry => entry.id) })
224
- }
225
- return { ok: ids.length > 0, count: sealedCatalog.length }
226
- } catch (error) {
227
- if (disposed) return
228
- if (Object.hasOwn(credential, 'userToken') && sealedCredential()?.userToken !== credential.userToken) return { ok: false, count: sealedCatalog.length, skipped: true }
229
- if (error?.code === CODE.credential) {
230
- sealedCatalog = []
231
- catalogStore.update({ sealIds: [] })
232
- logger.warn?.('our-free-model: the sealed lane refused its credential; its models are hidden until it is accepted again')
233
- return { ok: false, count: 0 }
234
- }
235
- logger.warn?.(`our-free-model: sealed lane listing failed (${error?.code ?? 'unknown'}); keeping its cached roster`)
236
- return { ok: false, count: sealedCatalog.length }
237
- }
238
- }
239
-
240
- async function refreshSealedLane() {
241
- await refreshSealedRoster()
242
- if (disposed) return
243
- // The rebuild in `mergeCatalogs` drops a stale roster on its own: a closed
244
- // lane's rows vanish without a filter pass over the merged view.
245
- mergeCatalogs()
246
- emitTopology()
247
- }
248
-
249
- async function refreshKiloRoster() {
250
- try {
251
- const payload = await fetchKiloListing({ signal: lifetime.signal })
252
- if (disposed) return
253
- if (payload?.error || !Array.isArray(payload?.data)) throw new Error('invalid Kilo model listing')
254
- kiloCatalog = buildKiloCatalog(payload.data)
255
- catalogStore.update({ kiloRows: kiloCatalog })
256
- return true
257
- } catch (error) {
258
- if (!disposed) logger.warn?.(`our-free-model: Kilo channel listing failed (${error?.code ?? error?.message ?? 'unknown'}); keeping its cached roster`)
259
- return false
260
- }
261
- }
262
-
263
- async function fetchListing() {
264
- if (disposed) return { data: [] }
265
- const started = Date.now()
266
- const listing = await getJson('/inference/v1/models', {
267
- session: sessionForConversation('catalog:our-free-model'),
268
- attributionUserAgent,
269
- signal: lifetime.signal,
270
- })
271
- if (!disposed) gatewayLatency = { ms: Date.now() - started, at: Date.now() }
272
- return listing
273
- }
274
-
275
- let probeRound = null
276
- let probeRoundGeneration = 0
277
- let probeThrottleStreak = 0
278
- let probeBackoffUntil = 0
279
- async function runProbeRound() {
280
- const generation = egressGeneration
281
- // The absorbed channels get no per-model probes: their verdicts would be
282
- // spent against a different gateway than the one that serves them, and a
283
- // roster the listing named is advertised as-is (its health is the listing
284
- // round's, refreshed on the same cadence). The aggregate lane is excluded
285
- // for a sharper reason — a probe there is a *real request to a provider the
286
- // user pays for*, spent to learn something the settings page already knows
287
- // (whether a key is configured). A combo is excluded because it has no
288
- // endpoint of its own: probing one would send the chain's own id to the free
289
- // lane as a model name, spending quota to collect a verdict about nothing.
290
- const probeable = catalog.filter(entry => !isEacEntry(entry) && !isKiloEntry(entry) && !isAggregateEntry(entry) && !isComboEntry(entry) && !isSubscriptionEntry(entry))
291
- const results = await probeCatalog(probeable, { attributionUserAgent, signal: lifetime.signal }, (id, result) => {
292
- if (disposed || generation !== egressGeneration) return
293
- availability.edit(value => ({
294
- ...value,
295
- results: {
296
- ...value.results,
297
- [id]: {
298
- state: result.state,
299
- ...result.detail === undefined ? {} : { detail: result.detail },
300
- ...result.ttftMs === undefined ? {} : { ttftMs: result.ttftMs },
301
- latencyMs: result.latencyMs, startedAt: result.startedAt, at: Date.now(),
302
- },
303
- },
304
- }))
305
- }, 2)
306
- if (disposed || generation !== egressGeneration) return {}
307
- availability.update({ at: Date.now(), egress })
308
- availability.flush()
309
- const verdicts = Object.values(results)
310
- if (verdicts.length > 0 && verdicts.every(row => row.state === STATE.unavailable)) {
311
- logger.warn?.(`our-free-model: the gateway refused all ${verdicts.length} models this round (${verdicts[0].detail ?? 'no detail'}); keeping them advertised`)
312
- }
313
- const allThrottled = verdicts.length > 0 && verdicts.every(row => row.state === STATE.throttled)
314
- probeThrottleStreak = allThrottled ? probeThrottleStreak + 1 : 0
315
- probeBackoffUntil = allThrottled
316
- ? Date.now() + Math.min(30 * 2 ** (probeThrottleStreak - 1), 120) * 60_000
317
- : 0
318
- if (allThrottled) {
319
- logger.warn?.(`our-free-model: the probe round hit the lane's quota; availability probes pause for ${Math.round((probeBackoffUntil - Date.now()) / 60_000)} minutes (your own requests are unaffected, and the reprobe button forces a round)`)
320
- }
321
- emitTopology()
322
- return results
323
- }
324
- async function refreshAvailability(force = false) {
325
- if (disposed || (!force && probeBackoffUntil > Date.now())) return {}
326
- if (probeRound !== null && probeRoundGeneration === egressGeneration) return probeRound
327
- probeRoundGeneration = egressGeneration
328
- const round = runProbeRound()
329
- probeRound = round
330
- try { return await round } finally { if (probeRound === round) probeRound = null }
331
- }
332
-
333
- async function watchEgress() {
334
- if (disposed) return false
335
- const seen = await detectEgress({ signal: lifetime.signal })
336
- if (disposed || seen === undefined) return false
337
- const previous = availability.get().egress
338
- const changed = previous === null || previous === undefined
339
- || previous.ip !== seen.ip || (seen.country !== undefined && previous.country !== seen.country)
340
- egress = seen
341
- if (changed) {
342
- egressGeneration++
343
- // 保留历史记录,但旧出口的成功、拒绝和手动测量均不能代表新出口。
344
- const expire = rows => Object.fromEntries(Object.entries(rows ?? {}).map(([id, row]) => [id, { ...row, stale: true }]))
345
- availability.edit(value => ({
346
- ...value, egress: seen, at: 0,
347
- results: expire(value.results), manualResults: expire(value.manualResults),
348
- }))
349
- availability.flush()
350
- probeThrottleStreak = 0
351
- probeBackoffUntil = 0
352
- emitTopology()
353
- // 只使旧判定失效并通知选择器,不发送任何自动探测请求。
354
- logger.info?.(`our-free-model: egress changed to ${seen.ip}${seen.country ? ` (${seen.country})` : ''}; previous availability marked stale`)
355
- }
356
- return true
357
- }
358
-
359
- const latestTests = new Map()
360
- async function testModel(id, effort = DEFAULT_LEVEL, { signal } = {}) {
361
- if (disposed) throw new UpstreamError('Model runtime stopped', CODE.aborted)
362
- if (settings.get().enabled === false) throw new UpstreamError('Inference is paused', CODE.client)
363
- const entry = catalog.find(candidate => candidate.id === id)
364
- if (!entry) throw new UpstreamError('Unknown model "' + id + '"', CODE.client, { status: 404 })
365
- const token = {}
366
- latestTests.set(id, token)
367
- const generation = egressGeneration
368
- const selectedEffort = effort === undefined || effort === '' ? DEFAULT_LEVEL : effort
369
- const linkedSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal
370
- const persist = row => {
371
- if (disposed || linkedSignal.aborted || latestTests.get(id) !== token || generation !== egressGeneration) return false
372
- availability.edit(value => ({ ...value, manualResults: { ...value.manualResults, [id]: row } }))
373
- availability.flush()
374
- emitTopology()
375
- return !availability.writeFailed
376
- }
377
- try {
378
- const measured = await runModelTest(testSignal => adapter.stream({
379
- provider: ROUTE_MAIN, model: entry.id,
380
- messages: [{ role: 'user', content: 'Reply with exactly: OK' }],
381
- reasoningEffort: selectedEffort, sessionId: 'bench:' + entry.id + ':' + selectedEffort,
382
- signal: testSignal,
383
- }, entry, state()), { signal: linkedSignal })
384
- if (linkedSignal.aborted || disposed) throw new UpstreamError('Text test cancelled', CODE.aborted)
385
- if (generation !== egressGeneration) throw new UpstreamError('Network egress changed during the text test; please test again', CODE.aborted)
386
- const persisted = persist({ state: STATE.available, source: 'manual', at: Date.now(), ttftMs: measured.ttftMs, latencyMs: measured.totalMs })
387
- return { model: entry.id, effort: selectedEffort, ok: true, ...measured, persisted }
388
- } catch (error) {
389
- // Cancellation is a user action, not a fresh availability verdict. Other failures
390
- // remain unknown instead of silently banning a paid/region-gated route.
391
- if (error?.code !== CODE.aborted) persist({ state: STATE.unknown, source: 'manual', at: Date.now(), detail: String(error?.message ?? error).slice(0, 200) })
392
- throw error
393
- } finally {
394
- if (latestTests.get(id) === token) latestTests.delete(id)
395
- }
396
- }
397
-
398
- return {
399
- testModel, state, adapter, refreshCatalog, refreshSealedLane, refreshAvailability, watchEgress, fetchListing,
400
- complete: createForwardCompletion({
401
- adapter, signal: lifetime.signal,
402
- state: () => {
403
- const snapshot = state()
404
- // Hiding advertisements must not invalidate requests using a saved id.
405
- return { ...snapshot, membership: computeMembership(snapshot.catalog, availability.get(), {
406
- ...snapshot.settings, hiddenModelIds: [], hideFreeModels: false, hideEacModels: false,
407
- }) }
408
- },
409
- }),
410
- publicModelRows: () => publicModelRows(state()),
411
- // A settings write that changes the aggregate lane or the chains (a key
412
- // saved, a subscription imported/toggled/removed, a combo edited) rebuilds
413
- // the merged view without an upstream listing round.
414
- syncAggregateCatalog() {
415
- if (disposed) return catalog
416
- mergeCatalogs()
417
- emitTopology()
418
- return catalog
419
- },
420
- get catalog() { return catalog },
421
- get egress() { return egress },
422
- get gatewayLatency() { return gatewayLatency },
423
- setAttributionUserAgent(value) { attributionUserAgent = value },
424
- dispose() {
425
- if (disposed) return
426
- disposed = true
427
- lifetime.abort()
428
- },
429
- }
430
- }
431
-
432
- function materializeCatalog(ids) {
433
- const rebuilt = buildCatalog(ids)
434
- return rebuilt.length > 0 ? rebuilt : FALLBACK_CATALOG
435
- }
436
-
437
- export function computeMembership(catalog, availabilitySnapshot, settings) {
438
- const expose = settings?.exposeRegionModels !== false
439
- const verdictOf = entry => modelAvailability(entry.id, availabilitySnapshot, Boolean(entry.channel)).availability
440
- const probeable = catalog.filter(entry => !entry.channel)
441
- const refusedAll = probeable.length > 0 && probeable.every(entry => verdictOf(entry) === STATE.unavailable)
442
- let usable = catalog.filter(entry => verdictOf(entry) !== STATE.unavailable)
443
- if (refusedAll) usable = catalog
444
- const main = []
445
- const region = []
446
- for (const entry of usable) {
447
- if (isModelHidden(entry, settings)) continue
448
- const verdict = verdictOf(entry)
449
- if (verdict !== STATE.regionBlocked) main.push(entry.id)
450
- else if (expose) region.push(entry.id)
451
- }
452
- const membership = {}
453
- if (main.length > 0) membership[ROUTE_MAIN] = main
454
- if (region.length > 0) membership[ROUTE_REGION] = region
455
- return membership
456
- }
package/src/core/stats.js DELETED
@@ -1,98 +0,0 @@
1
- import { MIN_DECODE_MS } from '../store.js'
2
- import { describeSavingsTotal, emptySavings, savingsPercent } from '../token-saver.js'
3
-
4
- export function buildStats(stats, catalog) {
5
- const days = stats.days ?? {}
6
- const series = Object.keys(days).sort().map(day => ({
7
- day,
8
- total: days[day].total ?? 0,
9
- models: Object.entries(days[day].models ?? {}).map(([model, value]) => ({ model, ...value })),
10
- }))
11
- const totals = {}
12
- for (const entry of series) for (const row of entry.models) {
13
- const previous = totals[row.model] ?? {
14
- model: row.model, input: 0, output: 0, reasoning: 0, calls: 0, failed: 0,
15
- ttftMs: 0, ttftSamples: 0, decodeMs: 0, decodeTokens: 0,
16
- }
17
- totals[row.model] = {
18
- ...previous,
19
- input: previous.input + row.input,
20
- output: previous.output + row.output,
21
- reasoning: previous.reasoning + row.reasoning,
22
- calls: previous.calls + row.calls,
23
- failed: previous.failed + row.failed,
24
- ttftMs: previous.ttftMs + (row.ttftMs ?? 0),
25
- ttftSamples: previous.ttftSamples + (row.ttftSamples ?? 0),
26
- decodeMs: previous.decodeMs + (row.decodeMs ?? 0),
27
- decodeTokens: previous.decodeTokens + (row.decodeTokens ?? 0),
28
- }
29
- }
30
- const logical = stats.logical ?? {}
31
- const logicalModels = logical.models ?? {}
32
- const logicalReady = Number.isSafeInteger(logical.turns)
33
- const lifetimeModels = stats.models ?? {}
34
- const empty = model => ({
35
- model, input: 0, output: 0, reasoning: 0, cacheRead: 0, calls: 0, failed: 0,
36
- ttftMs: 0, ttftSamples: 0, decodeMs: 0, decodeTokens: 0,
37
- })
38
- // The logical map is part of the row set, not just a lookup table: a combo
39
- // records its turn under the chain's own id and its physical calls under each
40
- // link's, so a chain that never appears here would show its links in the panel
41
- // and be invisible itself — the one row a user configuring a chain looks for.
42
- const modelIds = new Set([...Object.keys(totals), ...Object.keys(lifetimeModels), ...Object.keys(logicalModels)])
43
- const named = [...modelIds].map(model => {
44
- const row = totals[model] ?? empty(model)
45
- const lifetime = lifetimeModels[model] ?? {}
46
- const physical = {
47
- ...row,
48
- input: Number.isSafeInteger(lifetime.input) ? lifetime.input : row.input,
49
- output: Number.isSafeInteger(lifetime.output) ? lifetime.output : row.output,
50
- reasoning: Number.isSafeInteger(lifetime.reasoning) ? lifetime.reasoning : row.reasoning,
51
- cacheRead: Number.isSafeInteger(lifetime.cacheRead) ? lifetime.cacheRead : row.cacheRead,
52
- calls: Number.isSafeInteger(lifetime.calls) ? lifetime.calls : row.calls,
53
- failed: Number.isSafeInteger(lifetime.failed) ? lifetime.failed : row.failed,
54
- }
55
- return {
56
- ...physical,
57
- turns: logicalModels[model]?.turns ?? (logicalReady ? 0 : row.calls),
58
- failedTurns: logicalModels[model]?.failed ?? (logicalReady ? 0 : row.failed),
59
- recoveredTurns: logicalModels[model]?.recovered ?? 0,
60
- name: catalog.find(entry => entry.id === model)?.name ?? model,
61
- // A rate over too few measurable calls is a rounding error with a unit on it.
62
- tps: row.decodeMs >= MIN_DECODE_MS ? Math.round(row.decodeTokens / (row.decodeMs / 1000)) : null,
63
- avgTtftMs: row.ttftSamples > 0 ? Math.round(row.ttftMs / row.ttftSamples) : null,
64
- }
65
- })
66
- const physicalFailed = named.reduce((sum, row) => sum + row.failed, 0)
67
- const turns = logicalReady ? logical.turns : named.reduce((sum, row) => sum + row.turns, 0)
68
- const failedTurns = Number.isSafeInteger(logical.failed) ? logical.failed : named.reduce((sum, row) => sum + row.failedTurns, 0)
69
- const recoveredTurns = Number.isSafeInteger(logical.recovered) ? logical.recovered : named.reduce((sum, row) => sum + row.recoveredTurns, 0)
70
- const hasLifetimeFailures = Number.isSafeInteger(stats.failedRequests)
71
- const savings = { ...emptySavings(), ...(stats.savings ?? {}) }
72
- return {
73
- requests: stats.requests ?? 0,
74
- requestFailures: hasLifetimeFailures ? stats.failedRequests : physicalFailed,
75
- requestFailuresEstimated: stats.failedRequestsEstimated === true || !hasLifetimeFailures,
76
- logicalEstimated: logical.estimated === true,
77
- turns,
78
- failedTurns,
79
- recoveredTurns,
80
- days: series,
81
- models: named,
82
- samples: (stats.samples ?? []).slice(-200),
83
- // What the token savers measured. The percentage is computed here, from the
84
- // same bytes the rows above counted, so the panel never has to derive a rate
85
- // from two numbers that were rounded separately.
86
- savings: { ...savings, percent: savingsPercent(savings), summary: describeSavingsTotal(savings) },
87
- grand: {
88
- input: named.reduce((sum, row) => sum + row.input, 0),
89
- output: named.reduce((sum, row) => sum + row.output, 0),
90
- reasoning: named.reduce((sum, row) => sum + row.reasoning, 0),
91
- calls: named.reduce((sum, row) => sum + row.calls, 0),
92
- failed: hasLifetimeFailures ? stats.failedRequests : physicalFailed,
93
- turns,
94
- failedTurns,
95
- recoveredTurns,
96
- },
97
- }
98
- }
Binary file
Binary file