dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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.
@@ -136,7 +136,7 @@ export function createDshHostAdapter(typertGateway) {
136
136
  if (payload.beforeSeq !== undefined) {
137
137
  const page = requireRecord(await invoke('session', 'page', requestArgs({
138
138
  address: request.address,
139
- throughSeq: snapshot.cursor,
139
+ throughSeq: Number.isSafeInteger(payload.throughSeq) ? payload.throughSeq : snapshot.cursor,
140
140
  beforeSeq: payload.beforeSeq,
141
141
  ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
142
142
  }), signal), 'session/page')
package/lib/index.mjs CHANGED
@@ -135,6 +135,10 @@ import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh
135
135
  import { createSpeechInputManager, SPEECH_REQUEST_TYPES } from './speech-input.mjs'
136
136
  import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
137
137
  import { handleWorkspaceChanges, WORKSPACE_CHANGES_TYPES } from './workspace-changes-bridge.mjs'
138
+ import { handleCostMeter, COST_METER_REQUEST_TYPES, registerCostMeterProjection, registerCostMeterServices } from './cost-meter-bridge.mjs'
139
+ import { handleCostMeterWithDashboard } from './cost-meter-dashboard-bridge.mjs'
140
+ import { handleContextQuery, handleContextFleet, handleContextContent, registerContextProjection, registerContextServices, CONTEXT_REQUEST_TYPES } from './context-bridge.mjs'
141
+ import { handleAccount, ACCOUNT_REQUEST_TYPES } from './account-bridge.mjs'
138
142
  import { createSessionFollower } from './session-follower.mjs'
139
143
  import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
140
144
  import { createRelayState, PAIRING_MODES } from './relay-state.mjs'
@@ -289,6 +293,44 @@ function imagesOf(blocks) {
289
293
  return images
290
294
  }
291
295
 
296
+ function attachmentsOf(blocks) {
297
+ const attachments = []
298
+ for (const block of blocks || []) {
299
+ if (!block || typeof block !== 'object') continue
300
+ if (block.type === 'image' && block.attachment) {
301
+ attachments.push({ kind: 'image', ...imagesOf([block])[0] })
302
+ } else if (block.type === 'file') {
303
+ const ref = block.attachment || block.file || block
304
+ const item = {
305
+ kind: 'file',
306
+ ...(typeof block.receiptId === 'string' ? { receiptId: block.receiptId } : {}),
307
+ ...(typeof ref?.attachmentId === 'string' ? { attachmentId: ref.attachmentId } : {}),
308
+ ...(typeof ref?.name === 'string' ? { name: ref.name } : {}),
309
+ ...(Number.isSafeInteger(ref?.bytes) ? { bytes: ref.bytes } : {}),
310
+ ...(typeof ref?.mediaType === 'string' ? { mediaType: ref.mediaType } : {}),
311
+ }
312
+ if (item.receiptId || item.attachmentId || item.name) attachments.push(item)
313
+ } else if (block.type === 'directory' || block.type === 'folder') {
314
+ const path = block.path || block.name
315
+ if (typeof path === 'string' && path) attachments.push({ kind: 'directory', path, name: block.name || path })
316
+ }
317
+ }
318
+ return attachments
319
+ }
320
+
321
+ function workspaceAttachmentsOfText(text) {
322
+ if (typeof text !== 'string') return []
323
+ const attachments = []
324
+ const pattern = /^\[工作区(文件|目录):\s*([^\]\n]+)\]$/gm
325
+ let match
326
+ while ((match = pattern.exec(text))) {
327
+ const pathValue = match[2].trim()
328
+ if (!pathValue) continue
329
+ attachments.push({ kind: match[1] === '目录' ? 'directory' : 'file', path: pathValue, name: pathValue.split('/').pop() || pathValue })
330
+ }
331
+ return attachments
332
+ }
333
+
292
334
  // Build the small, owned JSON wire record for one session event. Reads only
293
335
  // leaf fields of the live SessionEvent — never serializes live objects.
294
336
  function buildWireEvent(session, event) {
@@ -300,13 +342,16 @@ function buildWireEvent(session, event) {
300
342
  switch (event.type) {
301
343
  case 'user/message': {
302
344
  const images = imagesOf(d.content || [])
345
+ const text = textOf(d.content || [])
346
+ const attachments = [...attachmentsOf(d.content || []), ...workspaceAttachmentsOfText(text)]
303
347
  return Object.assign(base, {
304
348
  event: {
305
349
  type: 'user/message',
306
- text: textOf(d.content || []),
350
+ text,
307
351
  source: d.source && d.source.kind,
308
352
  ...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
309
353
  ...(images.length ? { images } : {}),
354
+ ...(attachments.length ? { attachments } : {}),
310
355
  },
311
356
  })
312
357
  }
@@ -316,6 +361,7 @@ function buildWireEvent(session, event) {
316
361
  let reasoning = ''
317
362
  const toolCalls = []
318
363
  const images = imagesOf(blocks)
364
+ const attachments = attachmentsOf(blocks)
319
365
  for (const block of blocks) {
320
366
  if (!block) continue
321
367
  if (block.type === 'text' && typeof block.text === 'string') text += block.text
@@ -327,6 +373,7 @@ function buildWireEvent(session, event) {
327
373
  ...(d.interrupted === true ? { interrupted: true } : {}),
328
374
  ...(d.usage === undefined ? {} : { usage: d.usage }),
329
375
  ...(images.length ? { images } : {}),
376
+ ...(attachments.length ? { attachments } : {}),
330
377
  },
331
378
  })
332
379
  }
@@ -416,15 +463,15 @@ function buildWireEvent(session, event) {
416
463
  ...(typeof d.error === 'string' ? { error: d.error } : {}),
417
464
  },
418
465
  })
419
- // Keep live file declarations equivalent to durable history.
420
- case 'workspace/changes':
421
- return Object.assign(base, { event: { type: event.type, turn: d.turn } })
422
- case 'deliverables/presented':
423
- return Object.assign(base, { event: { type: event.type, turn: d.turn, callId: d.callId,
424
- files: (Array.isArray(d.files) ? d.files : []).filter(file => file && typeof file.path === 'string').map(file => ({
425
- path: file.path, ...(typeof file.description === 'string' ? { description: file.description } : {}),
426
- })),
427
- } })
466
+ // Keep live file declarations equivalent to durable history.
467
+ case 'workspace/changes':
468
+ return Object.assign(base, { event: { type: event.type, turn: d.turn } })
469
+ case 'deliverables/presented':
470
+ return Object.assign(base, { event: { type: event.type, turn: d.turn, callId: d.callId,
471
+ files: (Array.isArray(d.files) ? d.files : []).filter(file => file && typeof file.path === 'string').map(file => ({
472
+ path: file.path, ...(typeof file.description === 'string' ? { description: file.description } : {}),
473
+ })),
474
+ } })
428
475
  case 'turn/start':
429
476
  case 'turn/end':
430
477
  case 'step/start':
@@ -470,6 +517,25 @@ function parseWireImages(rawImages, requestType) {
470
517
  return { value: imageParts }
471
518
  }
472
519
 
520
+ function parseWireAttachments(rawAttachments, requestType) {
521
+ if (rawAttachments === undefined) return { value: [] }
522
+ if (!Array.isArray(rawAttachments) || rawAttachments.length > 6) {
523
+ return { error: { kind: 'error', code: 'bad-request', message: 'attachments must be an array with at most 6 items', requestType } }
524
+ }
525
+ const value = []
526
+ for (let index = 0; index < rawAttachments.length; index++) {
527
+ const item = rawAttachments[index]
528
+ if (!item || typeof item !== 'object' || !['file', 'directory'].includes(item.kind)
529
+ || typeof item.path !== 'string' || !item.path.trim() || item.path.includes('\0')
530
+ || path.isAbsolute(item.path) || item.path.split(/[\\/]+/).includes('..')) {
531
+ return { error: { kind: 'error', code: 'bad-request', message: `attachments[${index}] must be a workspace-relative file or directory path`, requestType } }
532
+ }
533
+ const cleanPath = item.path.trim().replace(/[\\]+/g, '/')
534
+ value.push({ kind: item.kind, path: cleanPath, ...(typeof item.name === 'string' && item.name ? { name: item.name.slice(0, 255) } : {}) })
535
+ }
536
+ return { value }
537
+ }
538
+
473
539
  function commandNameOf(line) {
474
540
  const trimmed = line.trim()
475
541
  if (!trimmed.startsWith('/')) return null
@@ -545,7 +611,12 @@ async function admitMessage(api, msg, notificationTracker) {
545
611
  const parsedImages = parseWireImages(msg.images, 'message')
546
612
  if (parsedImages.error) return parsedImages.error
547
613
  const imageParts = parsedImages.value
548
- if (!text && imageParts.length === 0) {
614
+ const parsedAttachments = parseWireAttachments(msg.attachments, 'message')
615
+ if (parsedAttachments.error) return parsedAttachments.error
616
+ const attachmentRefs = parsedAttachments.value
617
+ const attachmentText = attachmentRefs.map((item) => `[${item.kind === 'directory' ? '工作区目录' : '工作区文件'}: ${item.path}]`).join('\n')
618
+ const promptText = [text, attachmentText].filter(Boolean).join('\n')
619
+ if (!promptText && imageParts.length === 0) {
549
620
  return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
550
621
  }
551
622
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
@@ -578,10 +649,10 @@ async function admitMessage(api, msg, notificationTracker) {
578
649
  sessionId,
579
650
  mode,
580
651
  // Match the official WebUI ordering: images first, optional text last.
581
- content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
652
+ content: [...imageParts, ...(promptText ? [{ type: 'text', text: promptText }] : [])],
582
653
  ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
583
654
  })
584
- log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
655
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} attachments=${attachmentRefs.length} text="${promptText.slice(0, 60)}"`)
585
656
  return {
586
657
  kind: 'sent',
587
658
  sessionId,
@@ -1457,7 +1528,12 @@ async function handleClawbotControl(clawbotService, msg) {
1457
1528
  // Dispatch one mobile query frame; returns the wire frame to send back.
1458
1529
  async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
1459
1530
  if (msg.type === 'clawbot-control') return handleClawbotControl(pluginContext?.clawbotService, msg)
1531
+ if (COST_METER_REQUEST_TYPES.has(msg.type)) return handleCostMeterWithDashboard(api, msg, { hostContext: pluginContext?.hostContext })
1460
1532
  if (WORKSPACE_CHANGES_TYPES.has(msg.type)) return handleWorkspaceChanges(api, msg)
1533
+ if (msg.type === 'context-fleet') return handleContextFleet(msg)
1534
+ if (msg.type === 'context-content') return handleContextContent(api, msg)
1535
+ if (CONTEXT_REQUEST_TYPES.has(msg.type)) return handleContextQuery(api, msg)
1536
+ if (ACCOUNT_REQUEST_TYPES.has(msg.type)) return handleAccount(api, msg)
1461
1537
  if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
1462
1538
  if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1463
1539
  const session = requireSessionId(msg)
@@ -2257,6 +2333,19 @@ const plugin = {
2257
2333
  apply(ctx, config) {
2258
2334
  const webServer = ctx.webServer
2259
2335
  const api = createDshHostAdapter(ctx.typertGateway)
2336
+ // Optional lifecycle-bound services: hosts without session projections keep working.
2337
+ registerContextProjection(ctx)
2338
+ registerContextServices(ctx)
2339
+ // Cost meter is standalone: it registers its own `costMeter` session
2340
+ // projection (so the host folds usage into the projection cache) and reads
2341
+ // that back in-process — no `dsh-cost-meter` plugin required.
2342
+ registerCostMeterProjection(ctx)
2343
+ registerCostMeterServices(ctx)
2344
+ // Optional lifecycle-bound service: hosts without the DeepSeek account bundle keep working.
2345
+ ctx.inject(['accountController'], accountCtx => {
2346
+ api.account = accountCtx.accountController
2347
+ accountCtx.effect(() => () => { api.account = undefined })
2348
+ })
2260
2349
  const clawbotService = createClawbotService(ctx)
2261
2350
  ctx.effect(() => () => clawbotService.dispose())
2262
2351
  // Optional lifecycle-bound service: old hosts retain all existing gateway features.
@@ -2821,6 +2910,7 @@ const plugin = {
2821
2910
  api,
2822
2911
  currentVersion: PLUGIN_VERSION,
2823
2912
  requestId: crypto.randomUUID(),
2913
+ profileName: ctx.get?.('profileContext')?.name,
2824
2914
  })
2825
2915
  sendJson(res, 200, result)
2826
2916
  } finally {
@@ -3224,8 +3314,8 @@ const plugin = {
3224
3314
  msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
3225
3315
  msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
3226
3316
  msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
3227
- WORKSPACE_CHANGES_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type)) {
3228
- if ((WORKSPACE_CHANGES_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type) || msg.type === 'clawbot-control') && !ws.deviceId) {
3317
+ ACCOUNT_REQUEST_TYPES.has(msg.type) || COST_METER_REQUEST_TYPES.has(msg.type) || WORKSPACE_CHANGES_TYPES.has(msg.type) || CONTEXT_REQUEST_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type)) {
3318
+ if ((ACCOUNT_REQUEST_TYPES.has(msg.type) || COST_METER_REQUEST_TYPES.has(msg.type) || WORKSPACE_CHANGES_TYPES.has(msg.type) || CONTEXT_REQUEST_TYPES.has(msg.type) || PLUGIN_REQUEST_TYPES.has(msg.type) || msg.type === 'clawbot-control') && !ws.deviceId) {
3229
3319
  sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
3230
3320
  ...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
3231
3321
  message: msg.type === 'clawbot-control'
@@ -3242,6 +3332,7 @@ const plugin = {
3242
3332
  onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
3243
3333
  onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
3244
3334
  clawbotService,
3335
+ hostContext: ctx,
3245
3336
  }).then((frame) => {
3246
3337
  if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
3247
3338
  })
@@ -3298,6 +3389,11 @@ const plugin = {
3298
3389
  'session-rename',
3299
3390
  ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
3300
3391
  ...(api.workspaceChanges ? ['workspace-changes-v1'] : []),
3392
+ ...(api.account ? ['account-v1'] : []),
3393
+ 'cost-meter-v1',
3394
+ 'context-dashboard-v1',
3395
+ 'context-fleet-v1',
3396
+ 'context-content-v1',
3301
3397
  ],
3302
3398
  port: transport.port || webServer.port,
3303
3399
  clients: clients.size,
@@ -1,5 +1,6 @@
1
1
  export const PLUGIN_PACKAGE_NAME = 'dsh-plugin-weapp-gateway'
2
2
  const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`
3
+ const FAILED_INSTALL_STATES = new Set(['failed', 'cancelled', 'refused'])
3
4
 
4
5
  function errorField(value) {
5
6
  return typeof value === 'string' && value.trim() ? value.trim() : ''
@@ -22,6 +23,7 @@ export function formatPluginInstallError(error, depth = 0) {
22
23
  if (detail) return `${code && code !== detail ? `${code}: ` : ''}${detail}`.slice(0, 1000)
23
24
  }
24
25
 
26
+ if (code) return code
25
27
  try {
26
28
  const serialized = JSON.stringify(error, (key, value) => {
27
29
  if (/stack|authorization|cookie|password|secret|token|credential|api.?key/i.test(key)) return undefined
@@ -29,7 +31,28 @@ export function formatPluginInstallError(error, depth = 0) {
29
31
  })
30
32
  if (serialized && serialized !== '{}') return serialized.slice(0, 1000)
31
33
  } catch { /* use a generic message for cyclic or non-serializable errors */ }
32
- return code || 'The plugin manager did not provide an error message'
34
+ return 'The plugin manager did not provide an error message'
35
+ }
36
+
37
+ function operationErrorCode(error, depth = 0) {
38
+ if (!error || typeof error !== 'object' || depth > 4) return ''
39
+ if (typeof error.code === 'string') return error.code
40
+ for (const nested of [error.error, error.cause, error.details]) {
41
+ const code = operationErrorCode(nested, depth + 1)
42
+ if (code) return code
43
+ }
44
+ return ''
45
+ }
46
+
47
+ function failedOperation(operation) {
48
+ return FAILED_INSTALL_STATES.has(operation?.application) || Boolean(operation?.error)
49
+ }
50
+
51
+ function operationMessage(operation) {
52
+ if (operation?.error) return formatPluginInstallError(operation.error)
53
+ return typeof operation?.message === 'string' && operation.message.trim()
54
+ ? operation.message.trim()
55
+ : `Plugin manager reported ${operation?.application || 'an unknown failure'}`
33
56
  }
34
57
 
35
58
  function parseVersion(value) {
@@ -97,11 +120,14 @@ export async function checkPluginUpdate({ currentVersion, fetchImpl = globalThis
97
120
  }
98
121
  }
99
122
 
100
- export async function installLatestPlugin({ api, currentVersion, fetchImpl = globalThis.fetch, requestId }) {
123
+ export async function installLatestPlugin({ api, currentVersion, fetchImpl = globalThis.fetch, requestId, profileName }) {
101
124
  const update = await checkPluginUpdate({ currentVersion, fetchImpl })
102
125
  if (!update.updateAvailable) return { ...update, installed: false, reason: 'already-current' }
103
126
 
104
- const command = `dsh plugin --profile web add ${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
127
+ const profile = typeof profileName === 'string' && /^[A-Za-z0-9._-]{1,80}$/.test(profileName)
128
+ ? profileName
129
+ : 'web'
130
+ const command = `dsh plugin --profile ${profile} add ${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
105
131
  let inventory
106
132
  try {
107
133
  inventory = await api.plugins.inventory()
@@ -112,16 +138,83 @@ export async function installLatestPlugin({ api, currentVersion, fetchImpl = glo
112
138
  return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
113
139
  }
114
140
 
115
- const installation = await api.plugins.installBundle(
116
- `${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`,
117
- { requestId },
118
- )
119
- const errorMessage = formatPluginInstallError(installation?.error)
141
+ let bundles
142
+ try {
143
+ bundles = await api.plugins.listBundles()
144
+ } catch (error) {
145
+ return { ...update, installed: false, reason: 'plugin-manager-unavailable', command, detail: error?.message }
146
+ }
147
+ if (!Array.isArray(bundles)) {
148
+ return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
149
+ }
150
+
151
+ const existing = bundles.find(bundle => bundle?.name === PLUGIN_PACKAGE_NAME && bundle.installed === true)
152
+ const installSpec = `${PLUGIN_PACKAGE_NAME}@${update.latestVersion}`
153
+ const installOptions = {
154
+ requestId,
155
+ saveExact: true,
156
+ ...(typeof existing?.enabled === 'boolean' ? { enabled: existing.enabled } : {}),
157
+ }
158
+
159
+ let removed = false
160
+ if (existing) {
161
+ if (typeof api.plugins.removeBundle !== 'function') {
162
+ return { ...update, installed: false, reason: 'plugin-manager-unavailable', command }
163
+ }
164
+
165
+ let removal
166
+ try {
167
+ removal = await api.plugins.removeBundle(PLUGIN_PACKAGE_NAME)
168
+ } catch (error) {
169
+ removal = { application: 'failed', error }
170
+ }
171
+
172
+ // Older hosts cannot hot-remove a running bundle; their package manager can still replace it for restart.
173
+ if (failedOperation(removal) && operationErrorCode(removal) !== 'stop-profile') {
174
+ return {
175
+ ...update,
176
+ installed: true,
177
+ installation: {
178
+ ...removal,
179
+ application: FAILED_INSTALL_STATES.has(removal?.application) ? removal.application : 'failed',
180
+ errorMessage: operationMessage(removal),
181
+ },
182
+ }
183
+ }
184
+ removed = !failedOperation(removal)
185
+ }
186
+
187
+ let installation
188
+ try {
189
+ installation = await api.plugins.installBundle(installSpec, installOptions)
190
+ } catch (error) {
191
+ installation = { application: 'failed', error }
192
+ }
193
+
194
+ let errorMessage = ''
195
+ if (failedOperation(installation) && removed) {
196
+ const previousSpec = typeof existing.source === 'string' && existing.source
197
+ ? existing.source
198
+ : `${PLUGIN_PACKAGE_NAME}@${currentVersion}`
199
+ try {
200
+ const recovery = await api.plugins.installBundle(previousSpec, {
201
+ ...(typeof existing.enabled === 'boolean' ? { enabled: existing.enabled } : {}),
202
+ })
203
+ errorMessage = failedOperation(recovery)
204
+ ? `Update failed: ${operationMessage(installation)}. Restoring the previous version also failed: ${operationMessage(recovery)}`
205
+ : `Update failed: ${operationMessage(installation)}. The previous version was restored.`
206
+ installation = { ...installation, recovery }
207
+ } catch (error) {
208
+ errorMessage = `Update failed: ${operationMessage(installation)}. Restoring the previous version also failed: ${formatPluginInstallError(error)}`
209
+ }
210
+ }
211
+
212
+ const detail = errorMessage || (installation?.error ? formatPluginInstallError(installation.error) : '')
120
213
  return {
121
214
  ...update,
122
215
  installed: true,
123
- installation: errorMessage && installation && typeof installation === 'object'
124
- ? { ...installation, errorMessage }
216
+ installation: detail && installation && typeof installation === 'object'
217
+ ? { ...installation, errorMessage: detail }
125
218
  : installation,
126
219
  }
127
220
  }
@@ -0,0 +1,123 @@
1
+ /** 千问 / 阿里云资金账户可用金(issue #98)。凭据仅用于服务端 ACS3 签名。 */
2
+ import { createHash, createHmac, randomUUID } from 'node:crypto'
3
+ import { credentialRef } from './credentials-shim.mjs'
4
+ import { fetchWithRetry } from './net.mjs'
5
+
6
+ // API shape/endpoint: aliyun/alibabacloud-typescript-sdk, bssopenapi-20230930.
7
+ // Signature: https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature
8
+ export const ALIYUN_BALANCE_URL = 'https://business.aliyuncs.com/'
9
+ export const ALIYUN_BALANCE_CREDENTIAL_VARS = [
10
+ 'ALIBABA_CLOUD_ACCESS_KEY_ID',
11
+ 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
12
+ 'ALIBABA_CLOUD_SECURITY_TOKEN',
13
+ ]
14
+ const MAX_BYTES = 262144
15
+ const hash = value => createHash('sha256').update(value).digest('hex')
16
+
17
+ export function signAliyunBalanceRequest(accessKeyId, accessKeySecret, securityToken = '', { now = new Date(), nonce = randomUUID() } = {}) {
18
+ const headers = {
19
+ 'content-type': 'application/x-www-form-urlencoded',
20
+ host: 'business.aliyuncs.com',
21
+ 'x-acs-action': 'GetFundAccountAvailableAmount',
22
+ 'x-acs-content-sha256': hash(''),
23
+ 'x-acs-date': now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
24
+ 'x-acs-signature-nonce': nonce,
25
+ 'x-acs-version': '2023-09-30',
26
+ }
27
+ if (securityToken) headers['x-acs-security-token'] = securityToken
28
+ const keys = Object.keys(headers).sort()
29
+ const signedHeaders = keys.join(';')
30
+ const canonicalHeaders = keys.map(key => `${key}:${headers[key].trim()}\n`).join('')
31
+ const canonical = ['POST', '/', '', canonicalHeaders, signedHeaders, hash('')].join('\n')
32
+ const signature = createHmac('sha256', accessKeySecret).update(`ACS3-HMAC-SHA256\n${hash(canonical)}`).digest('hex')
33
+ headers.Authorization = `ACS3-HMAC-SHA256 Credential=${accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`
34
+ headers.Accept = 'application/json'
35
+ return { method: 'POST', headers, body: '', redirect: 'manual' }
36
+ }
37
+
38
+ const messages = {
39
+ zh: {
40
+ missing: '请配置阿里云 RAM AccessKey ID 和 AccessKey Secret(不是千问模型 API Key)',
41
+ network: '阿里云余额网络请求失败,请稍后重试',
42
+ redirect: '阿里云余额接口发生重定向,已拒绝转发凭据',
43
+ permission: '阿里云余额鉴权失败,请检查 AccessKey、STS 有效期及 RAM 权限 bss:DescribeBillingAccount',
44
+ http: '阿里云余额接口返回 HTTP {status}',
45
+ payload: '阿里云余额响应无效或过大,请稍后重试',
46
+ amount: '阿里云余额响应缺少有效的 AvailableAmount,未将其视为零余额',
47
+ currency: '阿里云余额响应缺少受支持的币种(CNY / USD / EUR)',
48
+ },
49
+ en: {
50
+ missing: 'Configure Alibaba Cloud RAM AccessKey ID and AccessKey Secret (not a Qianwen model API key)',
51
+ network: 'Alibaba Cloud balance network request failed; please retry later',
52
+ redirect: 'Alibaba Cloud balance redirect refused to protect credentials',
53
+ permission: 'Alibaba Cloud balance authentication failed; check AccessKey, STS expiry and RAM permission bss:DescribeBillingAccount',
54
+ http: 'Alibaba Cloud balance returned HTTP {status}',
55
+ payload: 'Alibaba Cloud balance response is invalid or too large; please retry later',
56
+ amount: 'Alibaba Cloud balance response has no valid AvailableAmount; it was not treated as zero',
57
+ currency: 'Alibaba Cloud balance response has no supported currency (CNY / USD / EUR)',
58
+ },
59
+ }
60
+ const failure = (locale, key, status = '') => new Error(messages[locale === 'en' ? 'en' : 'zh'][key].replace('{status}', String(status)))
61
+
62
+ export function parseAliyunBalance(data, locale = 'zh') {
63
+ const raw = data?.AvailableAmount
64
+ const text = typeof raw === 'string' ? raw.trim() : ''
65
+ const amount = typeof raw === 'number' ? raw : /^[+-]?\d+(\.\d+)?$/.test(text) ? Number(text) : NaN
66
+ if (!Number.isFinite(amount)) throw failure(locale, 'amount')
67
+ if (!['CNY', 'USD', 'EUR'].includes(data?.Currency)) throw failure(locale, 'currency')
68
+ // AvailableAmount 是可用金,包含信控/未结清款等因素;不能把它当成现金余额或消费额。
69
+ return { remaining: amount, unit: data.Currency, maxBudget: null, spend: null }
70
+ }
71
+
72
+ async function resolveCredential(ctx, name) {
73
+ try {
74
+ const hit = await ctx?.get?.('credentials')?.resolve(credentialRef(name))
75
+ if (typeof hit?.value === 'string' && hit.value.trim()) return hit.value.trim()
76
+ } catch { /* 与现有余额 adapter 一致,回落环境变量。 */ }
77
+ return String(process.env[name] ?? '').trim()
78
+ }
79
+
80
+ async function readPayload(response, locale) {
81
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) {
82
+ await response.body?.cancel().catch(() => {})
83
+ throw failure(locale, 'payload')
84
+ }
85
+ try {
86
+ const parts = []
87
+ let bytes = 0
88
+ for await (const part of response.body) {
89
+ bytes += part.length
90
+ if (bytes > MAX_BYTES) throw failure(locale, 'payload')
91
+ parts.push(part)
92
+ }
93
+ return JSON.parse(Buffer.concat(parts).toString('utf8'))
94
+ } catch { throw failure(locale, 'payload') }
95
+ }
96
+
97
+ export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch, signal } = {}) {
98
+ signal?.throwIfAborted()
99
+ const locale = config?.locale
100
+ const [ak, sk, token] = await Promise.all(ALIYUN_BALANCE_CREDENTIAL_VARS.map(name => resolveCredential(ctx, name)))
101
+ if (!ak || !sk) throw Object.assign(failure(locale, 'missing'), { soft: true })
102
+ let response
103
+ try {
104
+ // 每次网络重试重新生成时间戳与 nonce;URL、Action、body 均固定为只读账户查询。
105
+ response = await fetchWithRetry(ALIYUN_BALANCE_URL, { signal }, {
106
+ attempts: 2, timeoutMs: 15000,
107
+ fetchImpl: (url, init) => fetchImpl(url, { ...signAliyunBalanceRequest(ak, sk, token), signal: init.signal }),
108
+ })
109
+ } catch {
110
+ signal?.throwIfAborted()
111
+ throw failure(locale, 'network')
112
+ }
113
+ if (!response.ok) {
114
+ await response.body?.cancel().catch(() => {})
115
+ if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
116
+ if (response.status === 401 || response.status === 403) throw failure(locale, 'permission')
117
+ throw failure(locale, 'http', response.status)
118
+ }
119
+ const data = await readPayload(response, locale)
120
+ // 不把上游 Message/RequestId/账户身份下发浏览器;即使错误响应夹带余额也不能记作成功。
121
+ if ((data?.Code && data.Code !== 'Success') || data?.Success === false) throw failure(locale, 'permission')
122
+ return { label: config?.customBalance?.label || '千问 / 阿里云', ...parseAliyunBalance(data, locale) }
123
+ }