@apinggithub/mydsh-auto-model-router 0.2.6 → 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/config.mjs +61 -3
- package/index.mjs +26 -0
- package/package.json +2 -1
- package/quota-watch.mjs +260 -0
- package/router.mjs +73 -1
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
|
@@ -63,6 +63,10 @@ export class ModelRouter {
|
|
|
63
63
|
/** provider/model → context window learned from the host catalog. */
|
|
64
64
|
this.contextWindows = new Map()
|
|
65
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
|
|
66
70
|
/** @type {Map<string, object>} */
|
|
67
71
|
this.states = new Map()
|
|
68
72
|
}
|
|
@@ -208,12 +212,80 @@ export class ModelRouter {
|
|
|
208
212
|
const fresh = this.nearestFreshLevel(level)
|
|
209
213
|
if (fresh !== null) use = fresh
|
|
210
214
|
}
|
|
211
|
-
|
|
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
|
+
}
|
|
212
229
|
state.level = use
|
|
213
230
|
state.current = route
|
|
214
231
|
return route
|
|
215
232
|
}
|
|
216
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
|
+
|
|
217
289
|
/**
|
|
218
290
|
* Set the session mode (called by the index layer after a user answer).
|
|
219
291
|
* @param {string} sessionId
|