@feiyang666/dsh-usage-plugin 1.11.1 → 1.13.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/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  matchesModelProvider,
23
23
  parseBalanceResponse,
24
24
  providerList,
25
+ missingCredentialError,
25
26
  resolveBalanceEndpoint
26
27
  } from './balance.js'
27
28
 
@@ -286,10 +287,21 @@ export default {
286
287
  return undefined
287
288
  }
288
289
 
290
+ // SSE 实时推送:数据变化(新记录写入 / 导入 / 清除等)时通知订阅者。
291
+ // 订阅端点 /usage/api/events。无订阅者时是 no-op,插件独立使用不受影响。
292
+ const sseClients = new Set()
293
+ function notifyChanged() {
294
+ if (sseClients.size === 0) return
295
+ const payload = 'data: {"type":"changed","at":' + Date.now() + '}\n\n'
296
+ for (const res of sseClients) {
297
+ try { res.write(payload) } catch (e) { sseClients.delete(res) }
298
+ }
299
+ }
300
+
289
301
  function persistNow() {
290
302
  if (!dataPath || !persistOk) return Promise.resolve()
291
303
  const text = JSON.stringify(records)
292
- writeChain = writeChain.then(() => nfsWriteText(dataPath, text)).catch(() => {})
304
+ writeChain = writeChain.then(() => nfsWriteText(dataPath, text)).then(() => { notifyChanged() }).catch(() => {})
293
305
  return writeChain
294
306
  }
295
307
 
@@ -497,6 +509,11 @@ export default {
497
509
  if (provider === 'digital-ocean' || provider === 'digitalocean') {
498
510
  try { await refreshFxRate(false) } catch (e) {}
499
511
  }
512
+ // 兼容性:DeepSeek 系 provider 的 usage 不单独上报 cacheWriteTokens
513
+ // (缓存写入发生在未命中时,即 cacheWriteTokens == inputTokens)。
514
+ // 当上游未提供该字段时,用未命中 token 数(inputTokens)兜底,避免
515
+ // "缓存写入"列长期为空。
516
+ const cacheWrite = usage.cacheWriteTokens || usage.inputTokens || 0
500
517
  records.push({
501
518
  time: startedAt,
502
519
  model,
@@ -505,7 +522,7 @@ export default {
505
522
  inputTokens: usage.inputTokens || 0,
506
523
  outputTokens: usage.outputTokens || 0,
507
524
  cacheReadTokens: usage.cacheReadTokens || 0,
508
- cacheWriteTokens: usage.cacheWriteTokens || 0,
525
+ cacheWriteTokens: cacheWrite,
509
526
  reasoningTokens: usage.reasoningTokens || 0,
510
527
  finishReason,
511
528
  usdCnyRate: (provider === 'digital-ocean' || provider === 'digitalocean') ? (FX.rate || 0) : 0,
@@ -787,6 +804,124 @@ export default {
787
804
  credentialName: DIGITALOCEAN_CREDENTIAL
788
805
  }
789
806
  }
807
+ // ── Qwen / 百炼 Token Plan (console-token) ────────────────────────────
808
+ // 复用 bl CLI 保存的百炼控制台 OAuth access_token(~/.bailian/config.json),
809
+ // 调用百炼内部门户网关查询 Token Plan 配额用量,不需要阿里云 AccessKey。
810
+ const QWEN_GATEWAYS = {
811
+ 'cn-beijing-domestic': { host: 'bailian-cs.console.aliyun.com', action: 'BroadScopeAspnGateway' },
812
+ 'cn-beijing-international': { host: 'bailian-cs.console.alibabacloud.com', action: 'BroadScopeAspnGateway' },
813
+ 'ap-southeast-1-domestic': { host: 'modelstudio-cs.console.aliyun.com', action: 'IntlBroadScopeAspnGateway' },
814
+ 'ap-southeast-1-international': { host: 'bailian-singapore-cs.alibabacloud.com', action: 'IntlBroadScopeAspnGateway' }
815
+ }
816
+ const QWEN_API = 'zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage'
817
+
818
+ function readBailianConfigPath() {
819
+ const base = process.env.BAILIAN_CONFIG_DIR || joinPath(os.homedir(), '.bailian')
820
+ return joinPath(base, 'config.json')
821
+ }
822
+
823
+ async function readQwenConsoleToken() {
824
+ const cfgPath = readBailianConfigPath()
825
+ let raw
826
+ try { raw = await nfsReadText(cfgPath) } catch (e) {
827
+ throw new Error('未找到 ' + cfgPath + '。请先运行 `bl auth login --console` 完成百炼控制台登录。')
828
+ }
829
+ let cfg
830
+ try { cfg = JSON.parse(raw) } catch (e) {
831
+ throw new Error(cfgPath + ' 不是合法的 JSON 配置')
832
+ }
833
+ let token = cfg.access_token || cfg.accessToken
834
+ const active = cfg.active_config || 'default'
835
+ const profile = cfg[active]
836
+ if (profile && typeof profile === 'object') {
837
+ token = token || profile.access_token || profile.accessToken
838
+ }
839
+ if (!token || !String(token).trim()) {
840
+ throw new Error('配置中没有 access_token,请先运行 `bl auth login --console` 完成百炼控制台登录。')
841
+ }
842
+ return { token: String(token).trim(), region: String(cfg.console_region || 'cn-beijing'), site: String(cfg.console_site || 'domestic') }
843
+ }
844
+
845
+ async function queryQwenTokenPlan() {
846
+ const { token, region, site } = await readQwenConsoleToken()
847
+ const gw = QWEN_GATEWAYS[region + '-' + site] || QWEN_GATEWAYS['cn-beijing-domestic']
848
+ if (!gw) throw new Error('不支持的控制台地域/站点:' + region + '/' + site)
849
+ const payload = {
850
+ Api: QWEN_API,
851
+ V: '1.0',
852
+ Data: {
853
+ cornerstoneParam: {
854
+ protocol: 'V2',
855
+ console: 'ONE_CONSOLE',
856
+ productCode: 'p_efm',
857
+ switchUserType: 3,
858
+ consoleSite: 'BAILIAN_ALIYUN'
859
+ }
860
+ }
861
+ }
862
+ const body = 'params=' + encodeURIComponent(JSON.stringify(payload)) + '&region=' + encodeURIComponent(region)
863
+ const url = 'https://' + gw.host + '/cli/api.json'
864
+ + '?action=' + encodeURIComponent(gw.action)
865
+ + '&product=sfm_bailian&api=' + encodeURIComponent(QWEN_API)
866
+ // 与 bl CLI 一致:用 undici 的 EnvHttpProxyAgent 自动读取 HTTPS_PROXY/https_proxy
867
+ // 环境变量,走代理转发;未设置代理时则直连(EnvHttpProxyAgent 内部处理)。
868
+ // 因为子进程不保证能按模块名解析到 undici,这里预先算出 undici 的绝对入口路径并传给子进程。
869
+ let undiciPath = ''
870
+ try {
871
+ undiciPath = new URL(import.meta.resolve('undici')).pathname
872
+ } catch (e) {
873
+ // undici 是插件依赖,正常安装后必然存在;这里留空交由子进程报清楚错误。
874
+ undiciPath = ''
875
+ }
876
+ const script = [
877
+ 'const token=process.env.BAILIAN_TOKEN||"";',
878
+ 'const url=process.env.BAILIAN_API_URL||"";',
879
+ 'const body=process.env.BAILIAN_BODY||"";',
880
+ 'const undiciPath=process.env.UNDICI_PATH||"";',
881
+ 'if(!undiciPath){process.stdout.write(JSON.stringify({error:"undici 不可用,请确认插件依赖已安装"}));process.exit(0);}',
882
+ 'const m=require(undiciPath);',
883
+ 'try{m.setGlobalDispatcher(new m.EnvHttpProxyAgent())}catch(e){}',
884
+ 'const timeout=setTimeout(()=>{process.stdout.write(JSON.stringify({error:"timeout"}));process.exit(0)},20000);',
885
+ '(async()=>{',
886
+ ' try{',
887
+ ' const res=await m.fetch(url,{method:"POST",headers:{Authorization:"Bearer "+token,Accept:"*/*","Content-Type":"application/x-www-form-urlencoded","User-Agent":"dsh-usage-plugin"},body:body});',
888
+ ' clearTimeout(timeout);',
889
+ ' const b=await res.text();',
890
+ ' process.stdout.write(JSON.stringify({statusCode:res.status,contentType:String(res.headers.get("content-type")||""),body:b}));',
891
+ ' }catch(e){',
892
+ ' clearTimeout(timeout);',
893
+ ' process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}));',
894
+ ' }',
895
+ '})();'
896
+ ].join('\n')
897
+ const r = await spawnNode(script, null, {
898
+ BAILIAN_TOKEN: token,
899
+ BAILIAN_API_URL: url,
900
+ BAILIAN_BODY: body,
901
+ UNDICI_PATH: undiciPath
902
+ })
903
+ if (!r.ok) throw new Error(r.error || '请求失败')
904
+ let parsed
905
+ try { parsed = JSON.parse(r.out) } catch (e) { throw new Error('无法解析百炼网关输出') }
906
+ if (parsed.error) throw new Error(parsed.error)
907
+ if (parsed.statusCode !== 200) {
908
+ const hint = parsed.statusCode === 401 || parsed.statusCode === 403 ? ' 登录可能已过期,请重新运行 `bl auth login --console`。' : ''
909
+ throw new Error('接口返回 HTTP ' + parsed.statusCode + ':' + String(parsed.body || '').slice(0, 300) + hint)
910
+ }
911
+ // 网关返回的是带外层包装的 JSON(data.DataV2.data.data 才是配额字段),
912
+ // 这里解包后把扁平化的 JSON 文本交给 parseBalanceResponse。
913
+ let wrapper
914
+ try { wrapper = JSON.parse(parsed.body) } catch (e) { throw new Error('无法解析百炼网关响应') }
915
+ let inner = wrapper && wrapper.data
916
+ if (inner && inner.DataV2) {
917
+ const d2 = inner.DataV2.data
918
+ inner = (d2 && d2.data) != null ? d2.data : (d2 != null ? d2 : inner.DataV2)
919
+ } else if (inner) {
920
+ inner = inner.data || inner
921
+ }
922
+ if (!inner || typeof inner !== 'object') throw new Error('百炼网关响应格式不符合预期')
923
+ return JSON.stringify(inner)
924
+ }
790
925
 
791
926
  async function queryBalance(providerId) {
792
927
  const provider = getBalanceProvider(providerId)
@@ -794,6 +929,23 @@ export default {
794
929
  if (provider.queryMode === 'unsupported') {
795
930
  return balanceFailure(provider, 'AMD GPU Cloud 当前未公开可由推理 API Key 调用的余额查询端点;请在 AMD Developer Cloud 控制台查看 credits。', { unsupported: true, errorCode: 'unsupported' })
796
931
  }
932
+ if (provider.queryMode === 'console-token') {
933
+ try {
934
+ const bodyText = await queryQwenTokenPlan()
935
+ const normalized = parseBalanceResponse(provider.id, bodyText)
936
+ if (!normalized.ok) {
937
+ normalized.provider = provider.id
938
+ normalized.providerName = provider.name
939
+ normalized.credentialHelpUrl = provider.credentialHelpUrl || ''
940
+ } else {
941
+ normalized.credentialName = '百炼控制台 token'
942
+ normalized.credentialSource = readBailianConfigPath()
943
+ }
944
+ return normalized
945
+ } catch (e) {
946
+ return balanceFailure(provider, msg(e), { errorCode: 'console-token-error' })
947
+ }
948
+ }
797
949
  const credentials = ctx.get('credentials')
798
950
  if (!credentials) return balanceFailure(provider, '凭据服务不可用', { errorCode: 'credentials-unavailable' })
799
951
  const modelProfile = await configuredModelProvider(provider)
@@ -814,7 +966,7 @@ export default {
814
966
  ? '尚未保存 DigitalOcean 账户 Personal Access Token。请在此页面输入 dop_v1_ Token,保存后查询。'
815
967
  : provider.id === 'siliconflow'
816
968
  ? '模型提供商 ' + modelProfile.route + ' 引用了 ' + modelProfile.apiKeyEnv + ',但该凭据未配置。请在“设置 → 模型”中重新填写 API Key 并保存。'
817
- : '未找到 ' + provider.credentialHint + ',请配置后重试'
969
+ : missingCredentialError(provider).error
818
970
  return balanceFailure(provider, message, { errorCode: 'missing-credential', modelProviderRoute: modelProfile ? modelProfile.route : '' })
819
971
  }
820
972
  const endpoint = resolveBalanceEndpoint(provider.id, modelProfile && modelProfile.baseURL)
@@ -1182,6 +1334,27 @@ export default {
1182
1334
  } catch (e) {
1183
1335
  push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
1184
1336
  }
1337
+ try {
1338
+ // SSE 实时推送端点:数据变化时向订阅者推送 {"type":"changed"}。
1339
+ // 桌面端数据中心订阅该流即可即时感知用量数据更新。
1340
+ webServer.register({
1341
+ kind: 'exact',
1342
+ path: '/usage/api/events',
1343
+ handler: (req, res) => {
1344
+ res.writeHead(200, {
1345
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1346
+ 'Cache-Control': 'no-cache',
1347
+ Connection: 'keep-alive',
1348
+ 'X-Accel-Buffering': 'no',
1349
+ })
1350
+ res.write(':ok\n\n')
1351
+ sseClients.add(res)
1352
+ req.on('close', () => { sseClients.delete(res) })
1353
+ }
1354
+ })
1355
+ } catch (e) {
1356
+ push('events-route-register-threw: ' + msg(e))
1357
+ }
1185
1358
  } else {
1186
1359
  push('route-not-registered (no webServer)')
1187
1360
  }
package/package.json CHANGED
@@ -1,77 +1,80 @@
1
- {
2
- "name": "@feiyang666/dsh-usage-plugin",
3
- "version": "1.11.1",
4
- "description": "DeepSeek Harness usage & cost tracker plugin: per-call token/cache-hit stats, peak/off-peak billing, DeepSeek balance query, CSV/JSON/PNG export with custom destination, and persistent local storage. Ships a host half plus a web client half in one npm package; installs into a DSH profile as a dsh.bundle with one command (dsh plugin --profile web add @feiyang666/dsh-usage-plugin).",
5
- "keywords": [
6
- "deepseek",
7
- "harness",
8
- "dsh",
9
- "plugin",
10
- "usage",
11
- "cost",
12
- "tokens",
13
- "cache",
14
- "balance"
15
- ],
16
- "license": "MIT",
17
- "type": "module",
18
- "main": "lib/index.js",
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/feiyang-dev/dsh-usage-plugin.git"
22
- },
23
- "contributors": [
24
- {
25
- "name": "Martin-soaring-dev",
26
- "url": "https://github.com/Martin-soaring-dev"
27
- },
28
- {
29
- "name": "liu3734",
30
- "url": "https://github.com/liu3734"
31
- },
32
- {
33
- "name": "mumuer1024",
34
- "url": "https://github.com/mumuer1024"
35
- }
36
- ],
37
- "homepage": "https://github.com/feiyang-dev/dsh-usage-plugin#readme",
38
- "bugs": {
39
- "url": "https://github.com/feiyang-dev/dsh-usage-plugin/issues"
40
- },
41
- "exports": {
42
- ".": "./lib/index.js",
43
- "./client": "./lib/client.js",
44
- "./package.json": "./package.json"
45
- },
46
- "engines": {
47
- "node": ">=18"
48
- },
49
- "dsh": {
50
- "bundle": {
51
- "patch": "./cordis.patch.yml"
52
- },
53
- "client": {
54
- "platform": "web",
55
- "inject": [
56
- "@deepseek-ai/dsh-client-ui-conversation"
57
- ]
58
- }
59
- },
60
- "files": [
61
- "lib",
62
- "scripts",
63
- "cordis.patch.yml",
64
- "README.md",
65
- "README.zh.md",
66
- "CHANGELOG.md"
67
- ],
68
- "scripts": {
69
- "wire": "node scripts/wire.js",
70
- "check": "node scripts/check-package.js",
71
- "prepublishOnly": "node scripts/check-package.js",
72
- "pack": "npm pack"
73
- },
74
- "peerDependencies": {
75
- "@deepseek-ai/cordis": "^4.0.1"
76
- }
77
- }
1
+ {
2
+ "name": "@feiyang666/dsh-usage-plugin",
3
+ "version": "1.13.0",
4
+ "description": "DeepSeek Harness usage & cost tracker plugin: per-call token/cache-hit stats, peak/off-peak billing, DeepSeek balance query, CSV/JSON/PNG export with custom destination, and persistent local storage. Ships a host half plus a web client half in one npm package; installs into a DSH profile as a dsh.bundle with one command (dsh plugin --profile web add @feiyang666/dsh-usage-plugin).",
5
+ "keywords": [
6
+ "deepseek",
7
+ "harness",
8
+ "dsh",
9
+ "plugin",
10
+ "usage",
11
+ "cost",
12
+ "tokens",
13
+ "cache",
14
+ "balance"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "main": "lib/index.js",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/feiyang-dev/dsh-usage-plugin.git"
22
+ },
23
+ "contributors": [
24
+ {
25
+ "name": "Martin-soaring-dev",
26
+ "url": "https://github.com/Martin-soaring-dev"
27
+ },
28
+ {
29
+ "name": "liu3734",
30
+ "url": "https://github.com/liu3734"
31
+ },
32
+ {
33
+ "name": "mumuer1024",
34
+ "url": "https://github.com/mumuer1024"
35
+ }
36
+ ],
37
+ "homepage": "https://github.com/feiyang-dev/dsh-usage-plugin#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/feiyang-dev/dsh-usage-plugin/issues"
40
+ },
41
+ "exports": {
42
+ ".": "./lib/index.js",
43
+ "./client": "./lib/client.js",
44
+ "./package.json": "./package.json"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "inject": [
56
+ "@deepseek-ai/dsh-client-ui-conversation"
57
+ ]
58
+ }
59
+ },
60
+ "files": [
61
+ "lib",
62
+ "scripts",
63
+ "cordis.patch.yml",
64
+ "README.md",
65
+ "README.zh.md",
66
+ "CHANGELOG.md"
67
+ ],
68
+ "scripts": {
69
+ "wire": "node scripts/wire.js",
70
+ "check": "node scripts/check-package.js",
71
+ "prepublishOnly": "node scripts/check-package.js",
72
+ "pack": "npm pack"
73
+ },
74
+ "peerDependencies": {
75
+ "@deepseek-ai/cordis": "^4.0.1"
76
+ },
77
+ "dependencies": {
78
+ "undici": "^7.0.0"
79
+ }
80
+ }
@@ -4,7 +4,8 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
 
6
6
  const REPO = 'feiyang-dev/dsh-usage-plugin'
7
- const TAG = 'v1.11.1'
7
+ const VERSION = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).version
8
+ const TAG = `v${VERSION}`
8
9
 
9
10
  function getCredential() {
10
11
  const input = 'protocol=https\nhost=github.com\n\n'
@@ -76,9 +77,10 @@ if (created.status !== 201) {
76
77
  const release = JSON.parse(created.body)
77
78
  console.log('release id:', release.id, 'url:', release.html_url)
78
79
 
79
- // 打包并上传 tarball 作为附件(Windows 下 npm 为 npm.cmd 批处理脚本,需经 shell 执行)
80
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
81
- const pack = spawnSync(npmCmd, ['pack'], { encoding: 'utf8', shell: process.platform === 'win32' })
80
+ // 打包并上传 tarball 作为附件(Windows 下 npm 为 npm.cmd 批处理脚本,经 cmd /c 执行)
81
+ const pack = process.platform === 'win32'
82
+ ? spawnSync('cmd', ['/c', 'npm', 'pack'], { encoding: 'utf8' })
83
+ : spawnSync('npm', ['pack'], { encoding: 'utf8' })
82
84
  const tgzName = (pack.stdout || '').trim().split('\n').pop()
83
85
  if (tgzName && fs.existsSync(tgzName)) {
84
86
  const buf = fs.readFileSync(tgzName)