@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.
- package/README.md +15 -5
- package/cordis.patch.yml +1 -1
- package/index.js +3 -3
- package/package.json +3 -2
- package/src/catalog.js +98 -42
- package/src/core.js +749 -0
- package/src/engine-parts/win32-x64.000.part +0 -0
- package/src/engine-parts/win32-x64.001.part +0 -0
- package/src/engine-parts/win32-x64.002.part +0 -0
- package/src/engine-parts/win32-x64.003.part +0 -0
- package/src/engine-parts/win32-x64.004.part +0 -0
- package/src/engine-parts/win32-x64.005.part +0 -0
- package/src/engine-parts/win32-x64.006.part +0 -0
- package/src/http.js +3 -1
- package/src/magpie-engine.js +5 -0
- package/src/o2a.js +757 -0
- package/src/subscription-completion.js +1 -1
- package/src/upstream.js +241 -364
- package/vendor/magpie/NOTICE.md +151 -3
- package/vendor/magpie/engine.json +52 -27
- package/src/core/completion.js +0 -196
- package/src/core/runtime.js +0 -456
- package/src/core/stats.js +0 -98
- package/src/engine-parts/win32-x64.007.part +0 -0
- package/src/engine-parts/win32-x64.008.part +0 -0
package/src/core/runtime.js
DELETED
|
@@ -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
|