dsh-key-usage 0.1.2 → 0.1.4

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.
Binary file
Binary file
Binary file
package/lib/index.js CHANGED
@@ -1,18 +1,85 @@
1
1
  /**
2
- * dsh-key-usage 服务端入口:挂载只读 HTTP 路由,向客户端提供
3
- * 用量台账、通道配置、余额快照与会话用量的聚合数据。
2
+ * dsh-key-usage 服务端入口:声明计费规则配置项、挂载只读 HTTP 路由,
3
+ * 向客户端提供用量台账、通道配置、余额快照与会话用量的聚合数据。
4
4
  *
5
5
  * 数据来源:
6
6
  * - 台账 / 快照 / 通道配置:本地文件只读;
7
7
  * - 项目、会话、按小时:宿主 sessionPersistence 的只读 handle;
8
8
  * - 余额与订阅窗口:优先查各通道官方接口(凭据经宿主 credentials seam 解析,
9
- * 不落盘、不进日志、不返回给客户端),失败时回落到本地快照。
9
+ * 不落盘、不进日志、不返回给客户端),失败时回落到本地快照;
10
+ * - 计费规则:宿主设置服务(宿主负责落盘,本插件不写任何文件)。
10
11
  */
12
+ import z from '@deepseek-ai/schemastery';
11
13
  import { mountKeyUsageRoutes } from './routes.js';
12
14
  import { createLiveQuotaService } from './live-quota.js';
13
15
  import { createSessionSource } from './sessions.js';
16
+ import { EMPTY_PRICING, normalizePricing } from './pricing.js';
14
17
  export const name = 'dsh-key-usage';
15
- export function apply(ctx) {
18
+ /**
19
+ * 计费规则存在宿主设置服务的这个命名空间里。
20
+ *
21
+ * 宿主的「设置」服务并不维护独立存储:命名空间就是 profile 里**本条目的 id**
22
+ * (`cordis.patch.yml` 的 `insert.id`,与本包同名),字段必须用 `.volatile()` 标记才会被
23
+ * 列出;写入由宿主的 config-editor 落到当前 profile 的 cordis patch 上并原地生效,
24
+ * 因此本插件不写文件、也不需要重启宿主。
25
+ */
26
+ const SETTINGS_NAMESPACE = 'dsh-key-usage';
27
+ /** 规则的 Config 形状;数值全部可改,默认空规则 = 沿用台账反推均价(与升级前行为一致)。 */
28
+ const RulesSchema = z.array(z.object({
29
+ provider: z.string().default('*'),
30
+ model: z.string().default('*'),
31
+ unit: z.union(['perMillionTokens', 'perThousandTokens', 'perCall']).default('perMillionTokens'),
32
+ currency: z.string().default('CNY'),
33
+ input: z.number().default(0),
34
+ cacheRead: z.number().default(0),
35
+ cacheWrite: z.number().default(0),
36
+ output: z.number().default(0),
37
+ offPeak: z.object({
38
+ // `days`(ISO 1=周一 … 7=周日)缺省 = 每天适用;加它之前保存的规则没有这个字段,语义必须不变
39
+ windows: z.array(z.object({
40
+ from: z.string(),
41
+ to: z.string(),
42
+ days: z.array(z.number()).default([]),
43
+ })).default([]),
44
+ input: z.number().default(0),
45
+ cacheRead: z.number().default(0),
46
+ cacheWrite: z.number().default(0),
47
+ output: z.number().default(0),
48
+ }),
49
+ note: z.string().default(''),
50
+ }));
51
+ export const Config = z.object({
52
+ pricing: z.object({
53
+ version: z.number().default(1),
54
+ currency: z.string().default('CNY'),
55
+ rules: RulesSchema.default([]),
56
+ }).default(EMPTY_PRICING).volatile(),
57
+ });
58
+ /**
59
+ * 读当前规则:优先问宿主设置服务的实时投影(用户改完立即生效,无需重启宿主),
60
+ * 服务缺失(非桌面 / 未组配 dsh-settings)时退回插件自己的 Config 引用。
61
+ */
62
+ function readPricing(ctx, configRef) {
63
+ const settings = (typeof ctx.get === 'function' ? ctx.get('settings') : undefined);
64
+ if (settings !== undefined && typeof settings.describe === 'function') {
65
+ try {
66
+ const rows = settings.describe({ redactSecrets: false });
67
+ const row = Array.isArray(rows) ? rows.find((candidate) => candidate?.ns === SETTINGS_NAMESPACE) : undefined;
68
+ if (row !== undefined) {
69
+ return {
70
+ config: normalizePricing(row.value),
71
+ writable: true,
72
+ revision: typeof row.revision === 'number' ? row.revision : null,
73
+ };
74
+ }
75
+ }
76
+ catch {
77
+ // 设置服务尚未就绪(组合中或已停用):静默退回本地 Config
78
+ }
79
+ }
80
+ return { config: normalizePricing(configRef), writable: false, revision: null };
81
+ }
82
+ export function apply(ctx, config) {
16
83
  // 会话服务是可选依赖:用 get 惰性取,缺了也只是会话相关页面显示空态
17
84
  const sessionSource = createSessionSource({
18
85
  get: (serviceName) => (typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined),
@@ -40,6 +107,8 @@ export function apply(ctx) {
40
107
  host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota, {
41
108
  // workspaceRegistry 用来读官方归档集合(拿不到就回退本地 storages/workspace.json)
42
109
  get: (serviceName) => (typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined),
110
+ }, {
111
+ current: () => readPricing(ctx, config?.pricing),
43
112
  }), 'dsh-key-usage: http routes');
44
113
  });
45
114
  }
package/lib/live-quota.js CHANGED
@@ -11,7 +11,62 @@ function textOf(value) {
11
11
  return String(value);
12
12
  return null;
13
13
  }
14
- /** OpenCode Go:GET https://opencode.ai/zen/go/v1/usage → usage.rolling / weekly / monthly。 */
14
+ /** 金额字段:官方既有字符串(DeepSeek)也有数字(Moonshot),两种都接受,统一输出原样字符串。 */
15
+ function amountTextOf(value) {
16
+ if (typeof value === 'number')
17
+ return Number.isFinite(value) ? String(value) : null;
18
+ if (typeof value === 'string') {
19
+ const trimmed = value.trim();
20
+ return trimmed !== '' && Number.isFinite(Number(trimmed)) ? trimmed : null;
21
+ }
22
+ return null;
23
+ }
24
+ /** 金额 / 百分比的数值形态:接受 number 与数字字符串,其它一律 null。 */
25
+ function amountOf(value) {
26
+ const text = amountTextOf(value);
27
+ if (text === null)
28
+ return null;
29
+ const parsed = Number(text);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ /** epoch → ISO:13 位按毫秒、10 位按秒;越界返回 null。 */
33
+ function isoFromEpoch(value) {
34
+ const milliseconds = value >= 1e11 || value < 1e9 ? value : value * 1000;
35
+ const date = new Date(milliseconds);
36
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
37
+ }
38
+ /** 重置时间:官方为 ISO 字符串,兼容 epoch 毫秒 / 秒(数字或纯数字字符串);识别不出时原样保留。 */
39
+ function resetAtOf(value) {
40
+ if (typeof value === 'number')
41
+ return Number.isFinite(value) ? isoFromEpoch(value) : null;
42
+ if (typeof value !== 'string' || value === '')
43
+ return null;
44
+ const trimmed = value.trim();
45
+ if (/^\d{9,14}$/.test(trimmed))
46
+ return isoFromEpoch(Number(trimmed));
47
+ return value;
48
+ }
49
+ /** 接口没返回币种时的兜底:按站点判定(Z.AI 海外站按美元计价,BigModel / Moonshot 国内站按人民币)。 */
50
+ function currencyFromHost(requestUrl, fallback) {
51
+ let host = '';
52
+ try {
53
+ host = new URL(requestUrl).hostname.toLowerCase();
54
+ }
55
+ catch {
56
+ return fallback;
57
+ }
58
+ if (host === 'z.ai' || host.endsWith('.z.ai'))
59
+ return 'USD';
60
+ if (host === 'bigmodel.cn' || host.endsWith('.bigmodel.cn'))
61
+ return 'CNY';
62
+ if (host === 'moonshot.cn' || host.endsWith('.moonshot.cn'))
63
+ return 'CNY';
64
+ return fallback;
65
+ }
66
+ /**
67
+ * OpenCode Go:GET https://opencode.ai/zen/go/v1/usage → usage.rolling / weekly / monthly。
68
+ * 该端点未收录于官方文档(opencode.ai/docs/go 只指向 console),字段结构取自社区实现,见 test/fixtures/providers/opencode-go。
69
+ */
15
70
  const OPENCODE_GO_SCHEME = {
16
71
  url: () => 'https://opencode.ai/zen/go/v1/usage',
17
72
  parse: (body) => {
@@ -24,13 +79,14 @@ const OPENCODE_GO_SCHEME = {
24
79
  const raw = usage[sourceKey];
25
80
  if (!isObject(raw))
26
81
  continue;
27
- const percent = typeof raw.percent === 'number' ? raw.percent : Number(raw.percent);
28
- if (!Number.isFinite(percent))
82
+ // 社区实现里 percent 可能是字符串、resetsAt 可能是 epoch,两者都按漂移兼容处理
83
+ const percent = amountOf(raw.percent);
84
+ if (percent === null)
29
85
  continue;
30
86
  windows.push({
31
87
  key: windowKey,
32
88
  percent,
33
- resetsAt: textOf(raw.resetsAt),
89
+ resetsAt: resetAtOf(raw.resetsAt),
34
90
  status: typeof raw.status === 'string' ? raw.status : null,
35
91
  });
36
92
  }
@@ -41,22 +97,26 @@ const OPENCODE_GO_SCHEME = {
41
97
  function balanceScheme(path, parse, fallbackOrigin) {
42
98
  return {
43
99
  url: (baseURL) => new URL(path, baseURL ?? fallbackOrigin).href,
44
- parse: (body) => ({ balance: isObject(body) ? parse(body) : null, windows: null }),
100
+ parse: (body, requestUrl) => ({ balance: isObject(body) ? parse(body, requestUrl) : null, windows: null }),
45
101
  };
46
102
  }
47
103
  const SCHEMES = {
48
104
  'opencode-go': OPENCODE_GO_SCHEME,
49
105
  // DeepSeek: GET {origin}/user/balance → balance_infos[{currency,total_balance,granted_balance,topped_up_balance}]
50
106
  'deepseek-official': balanceScheme('/user/balance', (body) => {
51
- const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
52
- const info = infos.find((entry) => isObject(entry) && entry.currency === 'CNY') ?? infos[0];
53
- if (!isObject(info))
107
+ // 文档 balance_infos[].currency 只可能是 CNY / USD:没有 CNY 项时退回第一项,并如实保留它的币种
108
+ const infos = (Array.isArray(body.balance_infos) ? body.balance_infos : []).filter(isObject);
109
+ const info = infos.find((entry) => (textOf(entry.currency) ?? '').toUpperCase() === 'CNY') ?? infos[0];
110
+ if (info === undefined)
111
+ return null;
112
+ const total = amountTextOf(info.total_balance);
113
+ const granted = amountTextOf(info.granted_balance);
114
+ const toppedUp = amountTextOf(info.topped_up_balance);
115
+ // 三个金额全缺说明结构已经漂移,判 bad-json,不返回「只有币种」的空壳成功
116
+ if (total === null && granted === null && toppedUp === null)
54
117
  return null;
55
118
  const balance = {};
56
119
  const currency = textOf(info.currency);
57
- const total = textOf(info.total_balance);
58
- const granted = textOf(info.granted_balance);
59
- const toppedUp = textOf(info.topped_up_balance);
60
120
  if (currency !== null)
61
121
  balance.currency = currency;
62
122
  if (total !== null)
@@ -65,29 +125,31 @@ const SCHEMES = {
65
125
  balance.giftBalance = granted;
66
126
  if (toppedUp !== null)
67
127
  balance.topUpBalance = toppedUp;
68
- return Object.keys(balance).length > 0 ? balance : null;
128
+ return balance;
69
129
  }, 'https://api.deepseek.com'),
70
130
  // OpenRouter: GET {origin}/api/v1/credits(需要 Management Key)
71
131
  openrouter: balanceScheme('/api/v1/credits', (body) => {
72
132
  const data = isObject(body.data) ? body.data : null;
73
133
  if (data === null)
74
134
  return null;
75
- const totalCredits = typeof data.total_credits === 'number' ? data.total_credits : null;
76
- const totalUsage = typeof data.total_usage === 'number' ? data.total_usage : null;
135
+ const totalCredits = amountOf(data.total_credits);
136
+ const totalUsage = amountOf(data.total_usage);
77
137
  if (totalCredits === null || totalUsage === null)
78
138
  return null;
79
139
  return { currency: 'USD', totalBalance: (totalCredits - totalUsage).toFixed(2) };
80
140
  }, 'https://openrouter.ai'),
81
- // Moonshot / Kimi: GET {origin}/v1/users/me/balance
82
- moonshotai: balanceScheme('/v1/users/me/balance', (body) => {
141
+ // Moonshot / Kimi: GET {origin}/v1/users/me/balance(文档未定义 currency 字段,国内站按人民币计)
142
+ moonshotai: balanceScheme('/v1/users/me/balance', (body, requestUrl) => {
83
143
  const data = isObject(body.data) ? body.data : null;
84
144
  if (data === null)
85
145
  return null;
146
+ const available = amountTextOf(data.available_balance);
147
+ const cash = amountTextOf(data.cash_balance);
148
+ const voucher = amountTextOf(data.voucher_balance);
149
+ if (available === null && cash === null && voucher === null)
150
+ return null;
86
151
  const balance = {};
87
- const currency = textOf(data.currency);
88
- const available = textOf(data.available_balance);
89
- const cash = textOf(data.cash_balance);
90
- const voucher = textOf(data.voucher_balance);
152
+ const currency = textOf(data.currency) ?? currencyFromHost(requestUrl, null);
91
153
  if (currency !== null)
92
154
  balance.currency = currency;
93
155
  if (available !== null)
@@ -96,21 +158,23 @@ const SCHEMES = {
96
158
  balance.giftBalance = voucher;
97
159
  if (cash !== null)
98
160
  balance.topUpBalance = cash;
99
- return Object.keys(balance).length > 0 ? balance : null;
161
+ return balance;
100
162
  }, 'https://api.moonshot.cn'),
101
- // Z.AI / GLM: GET {origin}/api/paas/v4/balance
102
- zai: balanceScheme('/api/paas/v4/balance', (body) => {
163
+ // Z.AI / GLM: GET {origin}/api/paas/v4/balance(官方文档未收录,接口自带 currency 时才采信)
164
+ zai: balanceScheme('/api/paas/v4/balance', (body, requestUrl) => {
103
165
  const data = isObject(body.data) ? body.data : null;
104
166
  if (data === null)
105
167
  return null;
106
- const balance = { currency: 'CNY' };
107
- const total = textOf(data.total_balance);
108
- const available = textOf(data.available_balance);
168
+ const total = amountTextOf(data.total_balance);
169
+ const available = amountTextOf(data.available_balance);
170
+ if (total === null && available === null)
171
+ return null;
172
+ const balance = { currency: textOf(data.currency) ?? currencyFromHost(requestUrl, 'CNY') };
109
173
  if (total !== null)
110
174
  balance.totalBalance = total;
111
175
  if (available !== null)
112
176
  balance.availableBalance = available;
113
- return Object.keys(balance).length > 1 ? balance : null;
177
+ return balance;
114
178
  }, 'https://api.z.ai'),
115
179
  };
116
180
  /** provider id → 方案 id(事件里的 provider 可能是路由名,先取末段)。 */
@@ -133,7 +197,8 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
133
197
  const controller = new AbortController();
134
198
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
135
199
  try {
136
- const response = await fetchImpl(scheme.url(baseURL), {
200
+ const requestUrl = scheme.url(baseURL);
201
+ const response = await fetchImpl(requestUrl, {
137
202
  headers: { Authorization: 'Bearer ' + apiKey, Accept: 'application/json' },
138
203
  signal: controller.signal,
139
204
  });
@@ -142,7 +207,8 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
142
207
  if (!response.ok)
143
208
  return { balance: null, windows: null, error: 'http-' + String(response.status) };
144
209
  const raw = await response.text();
145
- if (raw.length > MAX_RESPONSE_BYTES)
210
+ // 上限按**字节**算:raw.length 是 UTF-16 码元数,中文响应会因此超限仍被接受
211
+ if (Buffer.byteLength(raw, 'utf8') > MAX_RESPONSE_BYTES)
146
212
  return { balance: null, windows: null, error: 'too-large' };
147
213
  let body;
148
214
  try {
@@ -151,7 +217,7 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
151
217
  catch {
152
218
  return { balance: null, windows: null, error: 'bad-json' };
153
219
  }
154
- const parsed = scheme.parse(body);
220
+ const parsed = scheme.parse(body, requestUrl);
155
221
  if (parsed.balance === null && parsed.windows === null)
156
222
  return { balance: null, windows: null, error: 'bad-json' };
157
223
  return { ...parsed, error: null };
package/lib/merge.js CHANGED
@@ -14,6 +14,7 @@ export function sessionDayRows(records, knownProviders) {
14
14
  inputTokens: model.buckets.inputTokens,
15
15
  outputTokens: model.buckets.outputTokens,
16
16
  cacheReadTokens: model.buckets.cacheReadTokens,
17
+ cacheWriteTokens: model.buckets.cacheWriteTokens,
17
18
  });
18
19
  }
19
20
  }
@@ -44,11 +45,13 @@ function synthesizeDay(date, rows) {
44
45
  let inputTokens = 0;
45
46
  let outputTokens = 0;
46
47
  let cacheReadTokens = 0;
48
+ let cacheWriteTokens = 0;
47
49
  for (const row of rows) {
48
50
  calls += row.calls;
49
51
  inputTokens += row.inputTokens;
50
52
  outputTokens += row.outputTokens;
51
53
  cacheReadTokens += row.cacheReadTokens;
54
+ cacheWriteTokens += row.cacheWriteTokens;
52
55
  const key = row.provider + '|' + row.model;
53
56
  const entry = byModel.get(key);
54
57
  if (entry === undefined) {
@@ -60,6 +63,7 @@ function synthesizeDay(date, rows) {
60
63
  inputTokens: row.inputTokens,
61
64
  outputTokens: row.outputTokens,
62
65
  cacheReadTokens: row.cacheReadTokens,
66
+ cacheWriteTokens: row.cacheWriteTokens,
63
67
  reasoningTokens: 0,
64
68
  // 会话事件没有官方成本,成本列对这些天留 0,由界面按计费模式展示
65
69
  cost: 0,
@@ -70,6 +74,7 @@ function synthesizeDay(date, rows) {
70
74
  entry.inputTokens += row.inputTokens;
71
75
  entry.outputTokens += row.outputTokens;
72
76
  entry.cacheReadTokens += row.cacheReadTokens;
77
+ entry.cacheWriteTokens += row.cacheWriteTokens;
73
78
  }
74
79
  const models = [...byModel.values()];
75
80
  return {
@@ -79,6 +84,7 @@ function synthesizeDay(date, rows) {
79
84
  inputTokens,
80
85
  outputTokens,
81
86
  cacheReadTokens,
87
+ cacheWriteTokens,
82
88
  reasoningTokens: 0,
83
89
  cost: 0,
84
90
  models: models.sort((first, second) => (second.inputTokens + second.outputTokens + second.cacheReadTokens)
@@ -120,12 +126,13 @@ export function mergeDailySeries(ledgerDays, sessionRows, from, to) {
120
126
  }
121
127
  /** 合并后的范围总计(展示口径 tokens 不含 cacheWrite)。 */
122
128
  export function totalsOfMerged(days) {
123
- const totals = { date: '', calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, cost: 0 };
129
+ const totals = { date: '', calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, cost: 0 };
124
130
  for (const day of days) {
125
131
  totals.calls += day.calls;
126
132
  totals.inputTokens += day.inputTokens;
127
133
  totals.outputTokens += day.outputTokens;
128
134
  totals.cacheReadTokens += day.cacheReadTokens;
135
+ totals.cacheWriteTokens += day.cacheWriteTokens;
129
136
  totals.reasoningTokens += day.reasoningTokens;
130
137
  totals.cost += day.cost;
131
138
  }