@apinggithub/mydsh-auto-model-router 0.2.5 → 0.3.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/auto-adapter.mjs +15 -4
- package/config.mjs +61 -3
- package/index.mjs +26 -0
- package/package.json +2 -1
- package/quota-watch.mjs +260 -0
- package/router.mjs +169 -5
package/auto-adapter.mjs
CHANGED
|
@@ -185,7 +185,11 @@ export function createAutoAdapter(resolve = {}) {
|
|
|
185
185
|
* indistinguishable from the model it forwards to.
|
|
186
186
|
*/
|
|
187
187
|
async resolveModel(provider, model) {
|
|
188
|
-
const
|
|
188
|
+
const router = readRouter()
|
|
189
|
+
// Teach the router the real windows first: the guard and this metadata
|
|
190
|
+
// must agree on what the candidates can hold.
|
|
191
|
+
await router?.refreshContextWindows?.()
|
|
192
|
+
const route = representativeRoute(router)
|
|
189
193
|
const info = await underlyingInfo(readLlm(), route)
|
|
190
194
|
const efforts = reasoningEfforts(info)
|
|
191
195
|
const requested = route?.reasoningEffort
|
|
@@ -193,9 +197,16 @@ export function createAutoAdapter(resolve = {}) {
|
|
|
193
197
|
? requested
|
|
194
198
|
: info?.reasoning?.defaultEffort
|
|
195
199
|
const defaultEffort = efforts.some((effort) => effort.id === wanted) ? wanted : efforts[0]?.id
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
// Report the smallest window any candidate can hold: the host budgets
|
|
201
|
+
// compaction from this value, so a conversation that fits the default
|
|
202
|
+
// level but not a free route would otherwise be assembled for a model
|
|
203
|
+
// that cannot take it.
|
|
204
|
+
const own = info?.context?.contextWindow
|
|
205
|
+
const pool = [
|
|
206
|
+
...(typeof router?.candidateContextWindows === 'function' ? router.candidateContextWindows() : []),
|
|
207
|
+
...(Number.isInteger(own) && own > 0 ? [own] : []),
|
|
208
|
+
].filter((value) => Number.isInteger(value) && value > 0)
|
|
209
|
+
const contextWindow = pool.length > 0 ? Math.min(...pool) : FALLBACK_CONTEXT_WINDOW
|
|
199
210
|
return {
|
|
200
211
|
provider,
|
|
201
212
|
id: model,
|
package/config.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// fallbackChain: [route, ...] (model-level, appended with the default)
|
|
23
23
|
// maxRetries: per-route retry budget on request-error
|
|
24
24
|
|
|
25
|
-
export const PLUGIN_VERSION = '0.
|
|
25
|
+
export const PLUGIN_VERSION = '0.3.0'
|
|
26
26
|
|
|
27
27
|
export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
|
|
28
28
|
export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
|
|
@@ -114,6 +114,27 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
114
114
|
// Re-read provider catalogs every N ms (also validated once at activation).
|
|
115
115
|
refreshMs: 600_000,
|
|
116
116
|
},
|
|
117
|
+
// Tier 7: quota watchdog. Reads the cloud bill (read-only, via the provider's
|
|
118
|
+
// billing API) and retires a route the moment a "free" model starts billing;
|
|
119
|
+
// a balance floor stops paid routes entirely. Off by default — it needs
|
|
120
|
+
// cloud credentials.
|
|
121
|
+
quotaWatch: {
|
|
122
|
+
enabled: false,
|
|
123
|
+
// Which configured provider the bill belongs to (routes to retire).
|
|
124
|
+
provider: 'bailian-dell-vostro',
|
|
125
|
+
// Cloud product code the bill is filtered by (`sfm` = 百炼).
|
|
126
|
+
productCode: 'sfm',
|
|
127
|
+
pollMs: 1_800_000,
|
|
128
|
+
// Stop choosing paid routes once the available balance drops below this.
|
|
129
|
+
// 0 disables the floor.
|
|
130
|
+
balanceFloorCny: 10,
|
|
131
|
+
// Retire a route whose model shows any spend in the current billing cycle.
|
|
132
|
+
demoteOnSpend: true,
|
|
133
|
+
// Credential refs resolved through ctx.credentials (never inline secrets).
|
|
134
|
+
accessKeyIdRef: 'ALIBABA_CLOUD_ACCESS_KEY_ID',
|
|
135
|
+
accessKeySecretRef: 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
|
|
136
|
+
timeoutMs: 20_000,
|
|
137
|
+
},
|
|
117
138
|
askEveryInputs: 3,
|
|
118
139
|
fallbackChain: [],
|
|
119
140
|
maxRetries: 1,
|
|
@@ -139,6 +160,7 @@ export function resolveConfig(input = {}) {
|
|
|
139
160
|
cfg.freeQuota = normalizeFreeQuota(cfg.freeQuota)
|
|
140
161
|
cfg.contextGuard = normalizeContextGuard(cfg.contextGuard)
|
|
141
162
|
cfg.modelsGuard = normalizeModelsGuard(cfg.modelsGuard)
|
|
163
|
+
cfg.quotaWatch = normalizeQuotaWatch(cfg.quotaWatch)
|
|
142
164
|
cfg.askEveryInputs = clampInt(cfg.askEveryInputs, 1, 100, DEFAULT_CONFIG.askEveryInputs)
|
|
143
165
|
cfg.fallbackChain = normalizeChain(cfg.fallbackChain)
|
|
144
166
|
cfg.maxRetries = clampInt(cfg.maxRetries, 0, 5, DEFAULT_CONFIG.maxRetries)
|
|
@@ -251,11 +273,47 @@ function normalizeHeuristic(heuristic) {
|
|
|
251
273
|
return h
|
|
252
274
|
}
|
|
253
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The quota watchdog: read-only cloud-billing polling.
|
|
278
|
+
*
|
|
279
|
+
* `provider` names which configured provider the bill describes (that is what
|
|
280
|
+
* gets retired when a model starts billing); the credential refs are resolved
|
|
281
|
+
* through `ctx.credentials`, so no secret ever lands in the profile.
|
|
282
|
+
*/
|
|
283
|
+
function normalizeQuotaWatch(quotaWatch) {
|
|
284
|
+
const q = {
|
|
285
|
+
...DEFAULT_CONFIG.quotaWatch,
|
|
286
|
+
...(quotaWatch ?? {}),
|
|
287
|
+
}
|
|
288
|
+
q.enabled = q.enabled === true
|
|
289
|
+
q.demoteOnSpend = q.demoteOnSpend !== false
|
|
290
|
+
q.provider = String(q.provider ?? '').trim()
|
|
291
|
+
q.productCode = String(q.productCode ?? '').trim()
|
|
292
|
+
q.accessKeyIdRef = String(q.accessKeyIdRef ?? '').trim()
|
|
293
|
+
q.accessKeySecretRef = String(q.accessKeySecretRef ?? '').trim()
|
|
294
|
+
q.pollMs = clampInt(q.pollMs, 60_000, 24 * 60 * 60 * 1000, DEFAULT_CONFIG.quotaWatch.pollMs)
|
|
295
|
+
q.balanceFloorCny = Number.isFinite(Number(q.balanceFloorCny)) && Number(q.balanceFloorCny) >= 0
|
|
296
|
+
? Number(q.balanceFloorCny)
|
|
297
|
+
: DEFAULT_CONFIG.quotaWatch.balanceFloorCny
|
|
298
|
+
q.timeoutMs = clampInt(q.timeoutMs, 5_000, 120_000, DEFAULT_CONFIG.quotaWatch.timeoutMs)
|
|
299
|
+
if (q.enabled) {
|
|
300
|
+
for (const [field, value] of [
|
|
301
|
+
['provider', q.provider],
|
|
302
|
+
['accessKeyIdRef', q.accessKeyIdRef],
|
|
303
|
+
['accessKeySecretRef', q.accessKeySecretRef],
|
|
304
|
+
]) {
|
|
305
|
+
if (value === '') {
|
|
306
|
+
throw new Error(`mydsh-auto-model-router: quotaWatch.${field} must be a non-empty string`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return q
|
|
311
|
+
}
|
|
312
|
+
|
|
254
313
|
function normalizeScoring(scoring) {
|
|
255
314
|
const s = {
|
|
256
315
|
...DEFAULT_CONFIG.scoring,
|
|
257
|
-
...(scoring ?? {}),
|
|
258
|
-
}
|
|
316
|
+
...(scoring ?? {}), }
|
|
259
317
|
const weights = {}
|
|
260
318
|
for (const id of LEVEL_IDS) {
|
|
261
319
|
const value = Number(s.weights?.[id])
|
package/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { resolveConfig } from './config.mjs'
|
|
30
30
|
import { ModelRouter } from './router.mjs'
|
|
31
31
|
import { createAutoAdapter, isAutoSelection } from './auto-adapter.mjs'
|
|
32
|
+
import { createQuotaWatch } from './quota-watch.mjs'
|
|
32
33
|
import {
|
|
33
34
|
reportStatus, persistStatusReport, statusReport, notifyDowngrade,
|
|
34
35
|
askDowngrade, resolveLocale, askModeSelection, askFixedLevel,
|
|
@@ -135,6 +136,30 @@ export function apply(ctx, input = {}) {
|
|
|
135
136
|
return () => clearInterval(timer)
|
|
136
137
|
}, 'modelRouter.catalogWatch()')
|
|
137
138
|
|
|
139
|
+
// ── quota watchdog: retire routes that stopped being free ───────────────
|
|
140
|
+
// Read-only billing poll (Aliyun RPC via a cloud key resolved from
|
|
141
|
+
// ctx.credentials); off unless configured, and inert when the host exposes no
|
|
142
|
+
// credentials service.
|
|
143
|
+
let quotaWatch
|
|
144
|
+
if (config.quotaWatch.enabled) {
|
|
145
|
+
const credentials = readService(ctx, 'credentials')
|
|
146
|
+
if (typeof credentials?.resolve === 'function') {
|
|
147
|
+
quotaWatch = createQuotaWatch({
|
|
148
|
+
config: config.quotaWatch,
|
|
149
|
+
router,
|
|
150
|
+
credentials,
|
|
151
|
+
logger: ctx.logger,
|
|
152
|
+
})
|
|
153
|
+
ctx.effect?.(() => quotaWatch.start(), 'modelRouter.quotaWatch()')
|
|
154
|
+
} else {
|
|
155
|
+
warn(
|
|
156
|
+
'quotaWatch',
|
|
157
|
+
'quotaWatch is enabled but the host exposes no credentials service; '
|
|
158
|
+
+ 'the watchdog stays off and routes are never retired for spend.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
// ── HTTP status endpoint for the browser settings section ─────────────
|
|
139
164
|
// The client fetch()es /api/mydsh-auto-model-router/status to render the
|
|
140
165
|
// current routing policy (no localStorage / DOM-transcript dependency).
|
|
@@ -159,6 +184,7 @@ export function apply(ctx, input = {}) {
|
|
|
159
184
|
freeQuota: config.freeQuota,
|
|
160
185
|
contextGuard: config.contextGuard,
|
|
161
186
|
modelsGuard: config.modelsGuard,
|
|
187
|
+
quotaWatch: quotaWatch?.snapshot() ?? { enabled: false },
|
|
162
188
|
staleLevels: router.staleLevels(),
|
|
163
189
|
rules: config.rules,
|
|
164
190
|
scoring: config.scoring,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apinggithub/mydsh-auto-model-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Hybrid model router for DeepSeek Harness: declarative rules, user-configurable cost control (cost-first / quality-first / balanced), optional LLM task classifier, and failure fallback chains.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"notify.mjs",
|
|
21
21
|
"auto-adapter.mjs",
|
|
22
22
|
"compat.mjs",
|
|
23
|
+
"quota-watch.mjs",
|
|
23
24
|
"text-width.mjs",
|
|
24
25
|
"scoring.mjs",
|
|
25
26
|
"client.js",
|
package/quota-watch.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Quota watchdog: read the cloud bill and retire routes that stopped being free.
|
|
2
|
+
//
|
|
3
|
+
// A "free" tier is only free until the provider silently switches it to
|
|
4
|
+
// pay-as-you-go; the router cannot see that from the request path (no error is
|
|
5
|
+
// raised). This module asks the billing API instead: once a model that should
|
|
6
|
+
// be free shows any spend in the current cycle, its route is retired for the
|
|
7
|
+
// session and the router drifts to the next candidate. A balance floor stops
|
|
8
|
+
// paid routes entirely before the account runs dry.
|
|
9
|
+
//
|
|
10
|
+
// Read-only: two GETs per poll (`QueryAccountBalance`, `DescribeInstanceBill`),
|
|
11
|
+
// signed with the Aliyun RPC scheme (HMAC-SHA1). No SDK, no dependencies.
|
|
12
|
+
import { createHmac, randomUUID } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
/** Default bailian product code on the Aliyun bill. */
|
|
15
|
+
export const BILL_PRODUCT_CODE = 'sfm'
|
|
16
|
+
|
|
17
|
+
/** RFC 3986 encoding as Aliyun's RPC signature expects. */
|
|
18
|
+
function percentEncode(value) {
|
|
19
|
+
return encodeURIComponent(String(value))
|
|
20
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
21
|
+
.replace(/%7E/g, '~')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a signed Aliyun RPC GET URL.
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {string} options.action
|
|
28
|
+
* @param {Record<string, string|number>} [options.params]
|
|
29
|
+
* @param {string} options.accessKeyId
|
|
30
|
+
* @param {string} options.accessKeySecret
|
|
31
|
+
* @param {string} [options.endpoint]
|
|
32
|
+
* @param {string} [options.version]
|
|
33
|
+
* @param {Date} [options.now]
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function signedUrl({
|
|
37
|
+
action,
|
|
38
|
+
params = {},
|
|
39
|
+
accessKeyId,
|
|
40
|
+
accessKeySecret,
|
|
41
|
+
endpoint = 'https://business.aliyuncs.com/',
|
|
42
|
+
version = '2017-12-14',
|
|
43
|
+
now = new Date(),
|
|
44
|
+
}) {
|
|
45
|
+
const common = {
|
|
46
|
+
Format: 'JSON',
|
|
47
|
+
Version: version,
|
|
48
|
+
AccessKeyId: accessKeyId,
|
|
49
|
+
SignatureMethod: 'HMAC-SHA1',
|
|
50
|
+
SignatureVersion: '1.0',
|
|
51
|
+
SignatureNonce: randomUUID(),
|
|
52
|
+
Timestamp: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
53
|
+
Action: action,
|
|
54
|
+
...params,
|
|
55
|
+
}
|
|
56
|
+
const canonical = Object.keys(common)
|
|
57
|
+
.sort()
|
|
58
|
+
.map((key) => `${percentEncode(key)}=${percentEncode(common[key])}`)
|
|
59
|
+
.join('&')
|
|
60
|
+
const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonical)}`
|
|
61
|
+
const signature = createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
|
|
62
|
+
return `${endpoint}?${canonical}&Signature=${percentEncode(signature)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract the model id from a 百炼 bill line (`id;instance;model;item;..`). */
|
|
66
|
+
export function modelOfBillItem(item) {
|
|
67
|
+
const parts = String(item?.InstanceID ?? '').split(';')
|
|
68
|
+
return (parts[2] ?? '').trim()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sum this cycle's bill by model.
|
|
73
|
+
* @param {object[]} items - DescribeInstanceBill `Items`
|
|
74
|
+
* @returns {{ byModel: Record<string, number>, total: number, lines: number }}
|
|
75
|
+
*/
|
|
76
|
+
export function aggregateBillItems(items = []) {
|
|
77
|
+
const byModel = {}
|
|
78
|
+
let total = 0
|
|
79
|
+
let lines = 0
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const model = modelOfBillItem(item)
|
|
82
|
+
if (model === '') continue
|
|
83
|
+
const cost = Number(item.AfterDiscountAmount ?? item.PretaxGrossAmount ?? 0)
|
|
84
|
+
if (!Number.isFinite(cost)) continue
|
|
85
|
+
byModel[model] = (byModel[model] ?? 0) + cost
|
|
86
|
+
total += cost
|
|
87
|
+
lines += 1
|
|
88
|
+
}
|
|
89
|
+
return { byModel, total, lines }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read the account's available balance in CNY. */
|
|
93
|
+
export async function fetchBalance({ accessKeyId, accessKeySecret, timeoutMs = 20_000, fetchImpl = fetch }) {
|
|
94
|
+
const url = signedUrl({ action: 'QueryAccountBalance', accessKeyId, accessKeySecret })
|
|
95
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
96
|
+
const body = JSON.parse(await response.text())
|
|
97
|
+
const data = body?.Data ?? {}
|
|
98
|
+
return {
|
|
99
|
+
availableCny: Number(data.AvailableAmount ?? 0),
|
|
100
|
+
currency: data.Currency ?? 'CNY',
|
|
101
|
+
raw: data,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read one billing cycle, following NextToken until exhausted. */
|
|
106
|
+
export async function fetchBill({
|
|
107
|
+
accessKeyId,
|
|
108
|
+
accessKeySecret,
|
|
109
|
+
cycle = new Date().toISOString().slice(0, 7),
|
|
110
|
+
maxPages = 10,
|
|
111
|
+
timeoutMs = 20_000,
|
|
112
|
+
fetchImpl = fetch,
|
|
113
|
+
}) {
|
|
114
|
+
const items = []
|
|
115
|
+
let token
|
|
116
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
117
|
+
const url = signedUrl({
|
|
118
|
+
action: 'DescribeInstanceBill',
|
|
119
|
+
params: { BillingCycle: cycle, MaxResults: 300, ...token === undefined ? {} : { NextToken: token } },
|
|
120
|
+
accessKeyId,
|
|
121
|
+
accessKeySecret,
|
|
122
|
+
})
|
|
123
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
124
|
+
const body = JSON.parse(await response.text())
|
|
125
|
+
const data = body?.Data ?? {}
|
|
126
|
+
items.push(...(data.Items ?? []))
|
|
127
|
+
token = data.NextToken
|
|
128
|
+
if (token === undefined || (data.Items ?? []).length === 0) break
|
|
129
|
+
}
|
|
130
|
+
return { cycle, items, ...aggregateBillItems(items) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the watchdog bound to one plugin mount.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} options
|
|
137
|
+
* @param {object} options.config - resolved `quotaWatch` config
|
|
138
|
+
* @param {object} options.router - ModelRouter
|
|
139
|
+
* @param {object} [options.credentials] - ctx.credentials (`resolve(ref) → {value}`)
|
|
140
|
+
* @param {object} [options.logger]
|
|
141
|
+
* @param {Function} [options.fetchImpl] - injection point for tests
|
|
142
|
+
* @param {() => Date} [options.now]
|
|
143
|
+
*/
|
|
144
|
+
export function createQuotaWatch({ config, router, credentials, logger, fetchImpl = fetch, now = () => new Date() }) {
|
|
145
|
+
let timer
|
|
146
|
+
let running = false
|
|
147
|
+
const retired = new Set()
|
|
148
|
+
let balanceWarned = false
|
|
149
|
+
let snapshot = {
|
|
150
|
+
enabled: config.enabled === true,
|
|
151
|
+
lastCheckAt: null,
|
|
152
|
+
lastError: null,
|
|
153
|
+
balanceCny: null,
|
|
154
|
+
cycle: null,
|
|
155
|
+
spendByModel: {},
|
|
156
|
+
totalCny: null,
|
|
157
|
+
exhausted: [],
|
|
158
|
+
balanceLow: false,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readSecret(ref) {
|
|
162
|
+
if (typeof credentials?.resolve !== 'function') {
|
|
163
|
+
throw new Error(`mydsh-auto-model-router: no credentials service for "${ref}"`)
|
|
164
|
+
}
|
|
165
|
+
const resolved = await credentials.resolve(ref)
|
|
166
|
+
const value = typeof resolved === 'string' ? resolved : resolved?.value
|
|
167
|
+
if (typeof value !== 'string' || value === '') {
|
|
168
|
+
throw new Error(`mydsh-auto-model-router: credential "${ref}" is not set`)
|
|
169
|
+
}
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Routes this watchdog guards: levels plus declared free routes. */
|
|
174
|
+
function guardedRoutes() {
|
|
175
|
+
const routes = []
|
|
176
|
+
for (const route of Object.values(router.levels ?? {})) if (route) routes.push(route)
|
|
177
|
+
for (const route of router.config?.freeQuota?.routes ?? []) if (route) routes.push(route)
|
|
178
|
+
return routes.filter((route) => route.provider === config.provider)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function check() {
|
|
182
|
+
if (running) return snapshot
|
|
183
|
+
running = true
|
|
184
|
+
try {
|
|
185
|
+
const accessKeyId = await readSecret(config.accessKeyIdRef)
|
|
186
|
+
const accessKeySecret = await readSecret(config.accessKeySecretRef)
|
|
187
|
+
const io = { accessKeyId, accessKeySecret, timeoutMs: config.timeoutMs, fetchImpl }
|
|
188
|
+
const balance = await fetchBalance(io)
|
|
189
|
+
const bill = await fetchBill({ ...io, cycle: now().toISOString().slice(0, 7) })
|
|
190
|
+
|
|
191
|
+
const floor = Number(config.balanceFloorCny) || 0
|
|
192
|
+
const low = floor > 0 && balance.availableCny < floor
|
|
193
|
+
if (low !== router.isBalanceLow()) {
|
|
194
|
+
router.setBalanceLow(low)
|
|
195
|
+
if (low) {
|
|
196
|
+
logger?.warn?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is below the ¥${floor} floor: paid routes are paused`)
|
|
197
|
+
balanceWarned = true
|
|
198
|
+
} else if (balanceWarned) {
|
|
199
|
+
logger?.info?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is above the floor again: paid routes resumed`)
|
|
200
|
+
balanceWarned = false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (config.demoteOnSpend) {
|
|
205
|
+
for (const route of guardedRoutes()) {
|
|
206
|
+
const spend = bill.byModel[route.model]
|
|
207
|
+
if (!(spend > 0)) continue
|
|
208
|
+
router.markRouteExhausted(route)
|
|
209
|
+
const key = `${route.provider}/${route.model}`
|
|
210
|
+
if (!retired.has(key)) {
|
|
211
|
+
retired.add(key)
|
|
212
|
+
logger?.warn?.(
|
|
213
|
+
`[mydsh-auto-model-router] ${key} billed ¥${spend.toFixed(4)} this cycle — `
|
|
214
|
+
+ 'it is not a free route any more; retired for this session',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
snapshot = {
|
|
221
|
+
enabled: true,
|
|
222
|
+
lastCheckAt: now().toISOString(),
|
|
223
|
+
lastError: null,
|
|
224
|
+
balanceCny: balance.availableCny,
|
|
225
|
+
cycle: bill.cycle,
|
|
226
|
+
spendByModel: bill.byModel,
|
|
227
|
+
totalCny: bill.total,
|
|
228
|
+
exhausted: router.exhaustedRouteKeys(),
|
|
229
|
+
balanceLow: router.isBalanceLow(),
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
snapshot = { ...snapshot, lastCheckAt: now().toISOString(), lastError: String(error?.message ?? error) }
|
|
233
|
+
logger?.warn?.(`[mydsh-auto-model-router] quota check failed: ${error?.message ?? error}`)
|
|
234
|
+
} finally {
|
|
235
|
+
running = false
|
|
236
|
+
}
|
|
237
|
+
return snapshot
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
check,
|
|
242
|
+
snapshot: () => snapshot,
|
|
243
|
+
/** Start polling; returns a disposer. */
|
|
244
|
+
start() {
|
|
245
|
+
if (config.enabled !== true) return () => {}
|
|
246
|
+
if (timer !== undefined) return () => stop()
|
|
247
|
+
// Kick off immediately, then on the configured cadence.
|
|
248
|
+
void check()
|
|
249
|
+
timer = setInterval(() => void check(), config.pollMs)
|
|
250
|
+
timer.unref?.()
|
|
251
|
+
return () => stop()
|
|
252
|
+
},
|
|
253
|
+
stop,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stop() {
|
|
257
|
+
if (timer !== undefined) clearInterval(timer)
|
|
258
|
+
timer = undefined
|
|
259
|
+
}
|
|
260
|
+
}
|
package/router.mjs
CHANGED
|
@@ -60,6 +60,13 @@ export class ModelRouter {
|
|
|
60
60
|
// read (or is empty): the freshness guard fails OPEN for that provider.
|
|
61
61
|
this.catalogs = new Map()
|
|
62
62
|
this.catalogCheckedAt = 0
|
|
63
|
+
/** provider/model → context window learned from the host catalog. */
|
|
64
|
+
this.contextWindows = new Map()
|
|
65
|
+
this.contextWindowsAt = 0
|
|
66
|
+
/** Routes retired by the quota watchdog (provider/model keys). */
|
|
67
|
+
this.exhaustedRoutes = new Set()
|
|
68
|
+
/** True while the account balance sits below the configured floor. */
|
|
69
|
+
this.balanceLow = false
|
|
63
70
|
/** @type {Map<string, object>} */
|
|
64
71
|
this.states = new Map()
|
|
65
72
|
}
|
|
@@ -83,6 +90,8 @@ export class ModelRouter {
|
|
|
83
90
|
*/
|
|
84
91
|
async decideOnInput(sessionId, { inputText = '', prevInputText = '', lastReplyText = '' } = {}) {
|
|
85
92
|
const state = this.stateFor(sessionId)
|
|
93
|
+
// Lazy catalog read: the guard can only honour windows it knows about.
|
|
94
|
+
await this.refreshContextWindows()
|
|
86
95
|
|
|
87
96
|
// Same input as the last decision (agent step replay, tool-call loop):
|
|
88
97
|
// reuse the existing decision — NO re-judging, NO re-classification.
|
|
@@ -203,12 +212,80 @@ export class ModelRouter {
|
|
|
203
212
|
const fresh = this.nearestFreshLevel(level)
|
|
204
213
|
if (fresh !== null) use = fresh
|
|
205
214
|
}
|
|
206
|
-
|
|
215
|
+
let route = this.effectiveRoute(state, sessionId, use)
|
|
216
|
+
// A route the quota watchdog retired (a "free" model that started billing,
|
|
217
|
+
// or any paid route once the balance floor is crossed) must not be picked.
|
|
218
|
+
if (this.routeBlocked(route)) {
|
|
219
|
+
for (const id of LEVEL_IDS) {
|
|
220
|
+
if (id === use) continue
|
|
221
|
+
const alternative = this.effectiveRoute(state, sessionId, id)
|
|
222
|
+
if (!this.routeBlocked(alternative)) {
|
|
223
|
+
use = id
|
|
224
|
+
route = alternative
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
207
229
|
state.level = use
|
|
208
230
|
state.current = route
|
|
209
231
|
return route
|
|
210
232
|
}
|
|
211
233
|
|
|
234
|
+
// ── quota watchdog state ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Retire a route: the watchdog keeps this out of routing until it is cleared.
|
|
238
|
+
* Fired when a route that should be free starts showing billable spend.
|
|
239
|
+
* @param {object} route - { provider, model }
|
|
240
|
+
*/
|
|
241
|
+
markRouteExhausted(route) {
|
|
242
|
+
if (!route?.provider || !route?.model) return
|
|
243
|
+
this.exhaustedRoutes.add(this.routeKey(route))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Re-admit a retired route (the next billing cycle resets every month). */
|
|
247
|
+
clearRouteExhausted(route) {
|
|
248
|
+
if (!route?.provider || !route?.model) return
|
|
249
|
+
this.exhaustedRoutes.delete(this.routeKey(route))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Every route the watchdog retired, as `provider/model` keys. */
|
|
253
|
+
exhaustedRouteKeys() {
|
|
254
|
+
return [...this.exhaustedRoutes]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether a route was retired by the watchdog. */
|
|
258
|
+
isRouteExhausted(route) {
|
|
259
|
+
if (!route?.provider || !route?.model) return false
|
|
260
|
+
return this.exhaustedRoutes.has(this.routeKey(route))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Account balance crossed the configured floor: paid routes stop being
|
|
265
|
+
* chosen until the watchdog clears the flag.
|
|
266
|
+
* @param {boolean} low
|
|
267
|
+
*/
|
|
268
|
+
setBalanceLow(low) {
|
|
269
|
+
this.balanceLow = low === true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether paid routes are currently refused. */
|
|
273
|
+
isBalanceLow() {
|
|
274
|
+
return this.balanceLow === true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Whether this route must not be dispatched to right now.
|
|
279
|
+
* @param {object} route
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
routeBlocked(route) {
|
|
283
|
+
if (!route) return true
|
|
284
|
+
if (this.isRouteExhausted(route)) return true
|
|
285
|
+
if (this.balanceLow && !this.isFreeRoute(route)) return true
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
212
289
|
/**
|
|
213
290
|
* Set the session mode (called by the index layer after a user answer).
|
|
214
291
|
* @param {string} sessionId
|
|
@@ -281,6 +358,91 @@ export class ModelRouter {
|
|
|
281
358
|
: 0.85
|
|
282
359
|
}
|
|
283
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Learn every candidate route's context window from the host catalog.
|
|
363
|
+
*
|
|
364
|
+
* The context guard can only skip a route whose window it knows, so an
|
|
365
|
+
* undeclared window fails open — and a long session then overflows the model.
|
|
366
|
+
* The provider truncates generation to the few tokens that still fit, which
|
|
367
|
+
* surfaces to the user as "reached the output token limit" on every retry.
|
|
368
|
+
*
|
|
369
|
+
* Runs at most once a minute; a provider that cannot answer for an id leaves
|
|
370
|
+
* that route unknown (fail open) and is retried on the next pass.
|
|
371
|
+
*/
|
|
372
|
+
async refreshContextWindows() {
|
|
373
|
+
if (typeof this.llm?.resolveModelInfo !== 'function') return
|
|
374
|
+
const now = Date.now()
|
|
375
|
+
if (now - this.contextWindowsAt < 60_000) return
|
|
376
|
+
this.contextWindowsAt = now
|
|
377
|
+
const routes = []
|
|
378
|
+
for (const id of LEVEL_IDS) {
|
|
379
|
+
const route = this.levels[id]
|
|
380
|
+
if (route) routes.push(route)
|
|
381
|
+
}
|
|
382
|
+
for (const route of this.config.freeQuota?.routes ?? []) {
|
|
383
|
+
if (route) routes.push(route)
|
|
384
|
+
}
|
|
385
|
+
for (const route of routes) {
|
|
386
|
+
const key = this.routeKey(route)
|
|
387
|
+
if (this.contextWindows.has(key)) continue
|
|
388
|
+
try {
|
|
389
|
+
const info = await this.llm.resolveModelInfo(route.provider, route.model)
|
|
390
|
+
const window = info?.context?.contextWindow
|
|
391
|
+
if (Number.isSafeInteger(window) && window > 0) this.contextWindows.set(key, window)
|
|
392
|
+
} catch {
|
|
393
|
+
// Provider does not answer for this id: leave the window unknown.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The window for one route: the value declared in config wins, then whatever
|
|
400
|
+
* the catalog taught us.
|
|
401
|
+
* @param {object | null | undefined} route
|
|
402
|
+
* @returns {number | undefined}
|
|
403
|
+
*/
|
|
404
|
+
contextWindowOf(route) {
|
|
405
|
+
if (!route) return undefined
|
|
406
|
+
if (Number.isFinite(route.contextWindow) && route.contextWindow > 0) return route.contextWindow
|
|
407
|
+
return this.contextWindows.get(this.routeKey(route))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Whether a route can hold `size` context tokens. An unknown window is
|
|
412
|
+
* assumed to fit, so a host without catalog access keeps working.
|
|
413
|
+
* @param {object} route
|
|
414
|
+
* @param {number} size
|
|
415
|
+
* @returns {boolean}
|
|
416
|
+
*/
|
|
417
|
+
fitsContext(route, size) {
|
|
418
|
+
const window = this.contextWindowOf(route)
|
|
419
|
+
if (!Number.isFinite(window) || window <= 0) return true
|
|
420
|
+
return size <= window * this.contextHeadroom()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Windows of every route a request for `level` can actually be dispatched to:
|
|
425
|
+
* the level itself plus the free routes covering it. The virtual `auto` model
|
|
426
|
+
* reports the smallest of them so the host never assembles a conversation
|
|
427
|
+
* that some candidate cannot hold.
|
|
428
|
+
* @param {string} [level]
|
|
429
|
+
* @returns {number[]}
|
|
430
|
+
*/
|
|
431
|
+
candidateContextWindows(level = this.config.costControl?.defaultLevel ?? 'L1') {
|
|
432
|
+
const out = []
|
|
433
|
+
const own = this.contextWindowOf(this.levels?.[level])
|
|
434
|
+
if (Number.isFinite(own)) out.push(own)
|
|
435
|
+
const index = levelIndex(level)
|
|
436
|
+
if (index !== -1 && typeof this.freeQuotaRoutes === 'function') {
|
|
437
|
+
for (const entry of this.freeQuotaRoutes()) {
|
|
438
|
+
if (levelIndex(entry.maxLevel) < index) continue
|
|
439
|
+
const window = this.contextWindowOf(entry)
|
|
440
|
+
if (Number.isFinite(window)) out.push(window)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
284
446
|
/**
|
|
285
447
|
* The conversation size the guard compares against: the input side of the
|
|
286
448
|
* last completed request in this session (uncached + cache-read + cache-write).
|
|
@@ -321,13 +483,10 @@ export class ModelRouter {
|
|
|
321
483
|
if (size <= 0) return level
|
|
322
484
|
const index = levelIndex(level)
|
|
323
485
|
if (index === -1) return level
|
|
324
|
-
const headroom = this.contextHeadroom()
|
|
325
486
|
const fits = (id) => {
|
|
326
487
|
const route = this.levels[id]
|
|
327
488
|
if (!route) return false
|
|
328
|
-
|
|
329
|
-
if (!Number.isFinite(window) || window <= 0) return true
|
|
330
|
-
return size <= window * headroom
|
|
489
|
+
return this.fitsContext(route, size)
|
|
331
490
|
}
|
|
332
491
|
if (fits(level)) return level
|
|
333
492
|
const ladder = []
|
|
@@ -513,10 +672,15 @@ export class ModelRouter {
|
|
|
513
672
|
if (!this.freeQuotaEnabled()) return null
|
|
514
673
|
const index = levelIndex(level)
|
|
515
674
|
if (index === -1) return null
|
|
675
|
+
// A free route that cannot hold the conversation is not a candidate: the
|
|
676
|
+
// provider would truncate the answer to whatever fits, which reads as
|
|
677
|
+
// "reached the output token limit".
|
|
678
|
+
const size = this.stateFor(sessionId).contextTokens ?? 0
|
|
516
679
|
for (const entry of this.freeQuotaRoutes()) {
|
|
517
680
|
if (levelIndex(entry.maxLevel) < index) continue
|
|
518
681
|
if (this.isExhausted(sessionId, entry)) continue
|
|
519
682
|
if (this.routeStale(entry)) continue
|
|
683
|
+
if (size > 0 && !this.fitsContext(entry, size)) continue
|
|
520
684
|
return entry
|
|
521
685
|
}
|
|
522
686
|
return null
|