@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 +23 -1
- package/package.json +1 -1
- package/quota-watch.mjs +73 -0
- package/router.mjs +25 -0
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.
|
|
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.
|
|
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
|
}
|