@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 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.2.6'
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.2.6",
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",
@@ -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
- const route = this.effectiveRoute(state, sessionId, use)
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