@apinggithub/mydsh-auto-model-router 0.3.0 → 0.3.1

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.3.0'
25
+ export const PLUGIN_VERSION = '0.3.1'
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'])
@@ -130,6 +130,12 @@ const DEFAULT_CONFIG = Object.freeze({
130
130
  balanceFloorCny: 10,
131
131
  // Retire a route whose model shows any spend in the current billing cycle.
132
132
  demoteOnSpend: true,
133
+ // Per-provider floor for DeepSeek's own platform balance (`/user/balance`),
134
+ // which no cloud bill covers. 0 disables it.
135
+ deepseekBalanceFloor: 0,
136
+ deepseekApiKeyRef: 'DEEPSEEK_API_KEY',
137
+ deepseekProvider: 'deepseek-official',
138
+ deepseekEndpoint: 'https://api.deepseek.com/user/balance',
133
139
  // Credential refs resolved through ctx.credentials (never inline secrets).
134
140
  accessKeyIdRef: 'ALIBABA_CLOUD_ACCESS_KEY_ID',
135
141
  accessKeySecretRef: 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
@@ -296,6 +302,22 @@ function normalizeQuotaWatch(quotaWatch) {
296
302
  ? Number(q.balanceFloorCny)
297
303
  : DEFAULT_CONFIG.quotaWatch.balanceFloorCny
298
304
  q.timeoutMs = clampInt(q.timeoutMs, 5_000, 120_000, DEFAULT_CONFIG.quotaWatch.timeoutMs)
305
+ q.deepseekBalanceFloor = Number.isFinite(Number(q.deepseekBalanceFloor)) && Number(q.deepseekBalanceFloor) >= 0
306
+ ? Number(q.deepseekBalanceFloor)
307
+ : DEFAULT_CONFIG.quotaWatch.deepseekBalanceFloor
308
+ q.deepseekApiKeyRef = String(q.deepseekApiKeyRef ?? '').trim()
309
+ q.deepseekProvider = String(q.deepseekProvider ?? '').trim()
310
+ q.deepseekEndpoint = String(q.deepseekEndpoint ?? '').trim() || DEFAULT_CONFIG.quotaWatch.deepseekEndpoint
311
+ if (q.deepseekBalanceFloor > 0) {
312
+ for (const [field, value] of [
313
+ ['deepseekApiKeyRef', q.deepseekApiKeyRef],
314
+ ['deepseekProvider', q.deepseekProvider],
315
+ ]) {
316
+ if (value === '') {
317
+ throw new Error(`mydsh-auto-model-router: quotaWatch.${field} must be a non-empty string when deepseekBalanceFloor is set`)
318
+ }
319
+ }
320
+ }
299
321
  if (q.enabled) {
300
322
  for (const [field, value] of [
301
323
  ['provider', q.provider],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apinggithub/mydsh-auto-model-router",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
package/quota-watch.mjs CHANGED
@@ -102,6 +102,40 @@ export async function fetchBalance({ accessKeyId, accessKeySecret, timeoutMs = 2
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * Read the DeepSeek platform balance (`GET /user/balance`).
107
+ *
108
+ * The `deepseek-official` provider bills against that account rather than any
109
+ * cloud bill, so it needs its own floor: the Aliyun watch cannot see it.
110
+ *
111
+ * @param {object} options
112
+ * @param {string} options.apiKey
113
+ * @param {string} [options.endpoint]
114
+ * @param {number} [options.timeoutMs]
115
+ * @param {Function} [options.fetchImpl]
116
+ */
117
+ export async function fetchDeepSeekBalance({
118
+ apiKey,
119
+ endpoint = 'https://api.deepseek.com/user/balance',
120
+ timeoutMs = 20_000,
121
+ fetchImpl = fetch,
122
+ }) {
123
+ const response = await fetchImpl(endpoint, {
124
+ headers: { authorization: `Bearer ${apiKey}` },
125
+ signal: AbortSignal.timeout(timeoutMs),
126
+ })
127
+ const body = JSON.parse(await response.text())
128
+ const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : []
129
+ const cny = infos.find((info) => info?.currency === 'CNY') ?? infos[0]
130
+ return {
131
+ available: body?.is_available !== false,
132
+ totalCny: Number(cny?.total_balance ?? 0),
133
+ grantedCny: Number(cny?.granted_balance ?? 0),
134
+ toppedUpCny: Number(cny?.topped_up_balance ?? 0),
135
+ currency: cny?.currency ?? 'CNY',
136
+ }
137
+ }
138
+
105
139
  /** Read one billing cycle, following NextToken until exhausted. */
106
140
  export async function fetchBill({
107
141
  accessKeyId,
@@ -146,6 +180,7 @@ export function createQuotaWatch({ config, router, credentials, logger, fetchImp
146
180
  let running = false
147
181
  const retired = new Set()
148
182
  let balanceWarned = false
183
+ let deepseekWarned = false
149
184
  let snapshot = {
150
185
  enabled: config.enabled === true,
151
186
  lastCheckAt: null,
@@ -156,6 +191,9 @@ export function createQuotaWatch({ config, router, credentials, logger, fetchImp
156
191
  totalCny: null,
157
192
  exhausted: [],
158
193
  balanceLow: false,
194
+ deepseekBalanceCny: null,
195
+ deepseekBlocked: false,
196
+ deepseekError: null,
159
197
  }
160
198
 
161
199
  async function readSecret(ref) {
@@ -201,6 +239,38 @@ export function createQuotaWatch({ config, router, credentials, logger, fetchImp
201
239
  }
202
240
  }
203
241
 
242
+ // DeepSeek bills against its own platform account, which the cloud bill
243
+ // never covers: give that provider its own floor.
244
+ let deepseekBalance = null
245
+ let deepseekError = null
246
+ const dsFloor = Number(config.deepseekBalanceFloor) || 0
247
+ if (dsFloor > 0) {
248
+ try {
249
+ const apiKey = await readSecret(config.deepseekApiKeyRef)
250
+ deepseekBalance = await fetchDeepSeekBalance({
251
+ apiKey,
252
+ endpoint: config.deepseekEndpoint,
253
+ timeoutMs: config.timeoutMs,
254
+ fetchImpl,
255
+ })
256
+ const blocked = deepseekBalance.totalCny < dsFloor
257
+ router.setProviderBlocked(config.deepseekProvider, blocked)
258
+ if (blocked && !deepseekWarned) {
259
+ deepseekWarned = true
260
+ logger?.warn?.(
261
+ `[mydsh-auto-model-router] ${config.deepseekProvider} balance ¥${deepseekBalance.totalCny.toFixed(2)} `
262
+ + `is below the ¥${dsFloor} floor: that provider is paused`,
263
+ )
264
+ } else if (!blocked && deepseekWarned) {
265
+ deepseekWarned = false
266
+ logger?.info?.(`[mydsh-auto-model-router] ${config.deepseekProvider} balance recovered: provider resumed`)
267
+ }
268
+ } catch (error) {
269
+ deepseekError = String(error?.message ?? error)
270
+ logger?.warn?.(`[mydsh-auto-model-router] deepseek balance check failed: ${deepseekError}`)
271
+ }
272
+ }
273
+
204
274
  if (config.demoteOnSpend) {
205
275
  for (const route of guardedRoutes()) {
206
276
  const spend = bill.byModel[route.model]
@@ -227,6 +297,9 @@ export function createQuotaWatch({ config, router, credentials, logger, fetchImp
227
297
  totalCny: bill.total,
228
298
  exhausted: router.exhaustedRouteKeys(),
229
299
  balanceLow: router.isBalanceLow(),
300
+ deepseekBalanceCny: deepseekBalance?.totalCny ?? null,
301
+ deepseekBlocked: router.isProviderBlocked(config.deepseekProvider),
302
+ deepseekError,
230
303
  }
231
304
  } catch (error) {
232
305
  snapshot = { ...snapshot, lastCheckAt: now().toISOString(), lastError: String(error?.message ?? error) }
package/router.mjs CHANGED
@@ -67,6 +67,8 @@ export class ModelRouter {
67
67
  this.exhaustedRoutes = new Set()
68
68
  /** True while the account balance sits below the configured floor. */
69
69
  this.balanceLow = false
70
+ /** Providers paused by a per-provider balance floor (e.g. deepseek-official). */
71
+ this.blockedProviders = new Set()
70
72
  /** @type {Map<string, object>} */
71
73
  this.states = new Map()
72
74
  }
@@ -274,6 +276,28 @@ export class ModelRouter {
274
276
  return this.balanceLow === true
275
277
  }
276
278
 
279
+ /**
280
+ * Pause every route of one provider (a provider whose own balance is below
281
+ * its floor). The rest of the ladder keeps working.
282
+ * @param {string} provider
283
+ * @param {boolean} blocked
284
+ */
285
+ setProviderBlocked(provider, blocked) {
286
+ if (typeof provider !== 'string' || provider === '') return
287
+ if (blocked) this.blockedProviders.add(provider)
288
+ else this.blockedProviders.delete(provider)
289
+ }
290
+
291
+ /** Whether a provider is paused by its own balance floor. */
292
+ isProviderBlocked(provider) {
293
+ return this.blockedProviders.has(provider)
294
+ }
295
+
296
+ /** Every provider currently paused, for the status report. */
297
+ blockedProviderList() {
298
+ return [...this.blockedProviders]
299
+ }
300
+
277
301
  /**
278
302
  * Whether this route must not be dispatched to right now.
279
303
  * @param {object} route
@@ -282,6 +306,7 @@ export class ModelRouter {
282
306
  routeBlocked(route) {
283
307
  if (!route) return true
284
308
  if (this.isRouteExhausted(route)) return true
309
+ if (this.isProviderBlocked(route.provider)) return true
285
310
  if (this.balanceLow && !this.isFreeRoute(route)) return true
286
311
  return false
287
312
  }