@kenz1117/dsh-ui-usage-billing 0.9.2 → 0.9.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.
package/lib/index.js CHANGED
@@ -27,11 +27,23 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
27
27
  const PLAN_KNOWLEDGE = {
28
28
  "opencode-go": {
29
29
  type: "code",
30
- subscriptionCny: 70
30
+ subscriptionCny: 70,
31
+ tier: {
32
+ amount: 10,
33
+ currency: "USD",
34
+ periodDays: 7,
35
+ label: "周额度 $30"
36
+ }
31
37
  },
32
38
  opencode: {
33
39
  type: "code",
34
- subscriptionCny: 70
40
+ subscriptionCny: 70,
41
+ tier: {
42
+ amount: 10,
43
+ currency: "USD",
44
+ periodDays: 7,
45
+ label: "周额度 $30"
46
+ }
35
47
  },
36
48
  "kimi-coding": {
37
49
  type: "code",
@@ -993,6 +1005,12 @@ function dayStamp(time) {
993
1005
  const pad = (n) => String(n).padStart(2, "0");
994
1006
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
995
1007
  }
1008
+ /** Local-time hour stamp `YYYY-MM-DDTHH` — the performance series bucket key. */
1009
+ function hourStamp(time) {
1010
+ const date = new Date(time);
1011
+ const pad = (n) => String(n).padStart(2, "0");
1012
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}`;
1013
+ }
996
1014
  /** 工作区名:取 cwd 的末级目录名;无 cwd 时返回 {@link UNKNOWN_WORKSPACE_NAME}。 */
997
1015
  function workspaceNameOf(cwd) {
998
1016
  if (cwd === void 0 || cwd === "") return "—";
@@ -1066,6 +1084,7 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1066
1084
  byDayModels: /* @__PURE__ */ new Map(),
1067
1085
  planCalls: /* @__PURE__ */ new Map(),
1068
1086
  turns: [],
1087
+ perf: [],
1069
1088
  roles: {
1070
1089
  userChars: 0,
1071
1090
  toolChars: 0,
@@ -1078,6 +1097,8 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1078
1097
  let subscription = false;
1079
1098
  let official = false;
1080
1099
  const turns = /* @__PURE__ */ new Map();
1100
+ const steps = /* @__PURE__ */ new Map();
1101
+ let lastOpenStepKey;
1081
1102
  for (const event of events) {
1082
1103
  fold.lastActive = Math.max(fold.lastActive, event.time);
1083
1104
  if (event.type === "session/title") {
@@ -1104,11 +1125,39 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1104
1125
  if (state !== void 0) state.endedAt = event.time;
1105
1126
  continue;
1106
1127
  }
1128
+ if (event.type === "step/start") {
1129
+ const turn = event.data.turn;
1130
+ const step = event.data.step;
1131
+ if (typeof turn === "number" && typeof step === "number") {
1132
+ const stepKey = `${turn}:${step}`;
1133
+ steps.set(stepKey, { startTime: event.time });
1134
+ lastOpenStepKey = stepKey;
1135
+ }
1136
+ continue;
1137
+ }
1107
1138
  if (event.type === "request/header") {
1108
1139
  const { model, provider } = event.data.header.config;
1109
1140
  key = resolveCatalogKey(model);
1110
1141
  subscription = subscriptionProviders.has(provider);
1111
1142
  official = officialProviderIds === void 0 ? isOfficialProvider(provider) : officialProviderIds.has(provider);
1143
+ if (lastOpenStepKey !== void 0) {
1144
+ const stepState = steps.get(lastOpenStepKey);
1145
+ if (stepState !== void 0 && stepState.requestTime === void 0) stepState.requestTime = event.time;
1146
+ }
1147
+ continue;
1148
+ }
1149
+ if (event.type === "assistant/chunk") {
1150
+ const data = event.data;
1151
+ const turn = data.turn;
1152
+ const step = data.step;
1153
+ const chunk = data.chunk;
1154
+ if (typeof turn === "number" && typeof step === "number" && chunk !== void 0 && chunk.type !== "usage" && chunk.type !== "finish") {
1155
+ const state = steps.get(`${turn}:${step}`);
1156
+ if (state !== void 0) {
1157
+ if (state.firstContentTime === void 0) state.firstContentTime = event.time;
1158
+ state.lastContentTime = event.time;
1159
+ }
1160
+ }
1112
1161
  continue;
1113
1162
  }
1114
1163
  if (event.type !== "assistant/message") continue;
@@ -1121,7 +1170,8 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1121
1170
  foldUsage(usageCell(fold.byDay, day), usage, modelKey, subscription, event.time, official);
1122
1171
  foldUsage(modelDayCell(fold.byDayModels, day, modelKey), usage, modelKey, subscription, event.time, official);
1123
1172
  if (subscription) fold.planCalls.set(modelKey, (fold.planCalls.get(modelKey) ?? 0) + 1);
1124
- const state = turnState(turns, event.data.turn ?? -1);
1173
+ const turn = event.data.turn ?? -1;
1174
+ const state = turnState(turns, turn);
1125
1175
  state.model = modelKey;
1126
1176
  state.input += usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
1127
1177
  state.output += usage.outputTokens;
@@ -1146,6 +1196,15 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1146
1196
  fold.roles.inputCost += fullCost - outputCost;
1147
1197
  }
1148
1198
  if (state.startedAt === Number.MAX_SAFE_INTEGER) state.startedAt = event.time;
1199
+ const stepNum = event.data.step;
1200
+ if (typeof stepNum === "number") {
1201
+ const perfState = steps.get(`${turn}:${stepNum}`);
1202
+ if (perfState !== void 0) {
1203
+ const sample = perfSampleOf(perfState, modelKey, usage.outputTokens ?? 0, event.time);
1204
+ if (sample !== void 0) fold.perf.push(sample);
1205
+ steps.delete(`${turn}:${stepNum}`);
1206
+ }
1207
+ }
1149
1208
  }
1150
1209
  fold.turns = [...turns.values()].filter((state) => state.input > 0 || state.output > 0).sort((a, b) => a.turn - b.turn).map((state) => ({
1151
1210
  turn: state.turn,
@@ -1160,6 +1219,29 @@ function foldSession(events, subscriptionProviders, officialProviderIds) {
1160
1219
  }));
1161
1220
  return fold;
1162
1221
  }
1222
+ /**
1223
+ * 生成一个 step 的性能样本;无效 / 超出 sane 上限(15 分钟)时返回 undefined,
1224
+ * 避免单条异常记录(时区错位 / 服务端抖动)拉偏均值。
1225
+ */
1226
+ function perfSampleOf(state, model, outputTokens, endTime) {
1227
+ const start = state.requestTime ?? state.startTime;
1228
+ const first = state.firstContentTime;
1229
+ const last = state.lastContentTime;
1230
+ if (start === void 0 || first === void 0 || first < start) return void 0;
1231
+ const ttftMs = first - start;
1232
+ if (!Number.isFinite(ttftMs) || ttftMs < 0 || ttftMs > 9e5) return void 0;
1233
+ const genMs = last !== void 0 && last > first ? last - first : void 0;
1234
+ const latencyMs = endTime >= start ? endTime - start : void 0;
1235
+ const tps = genMs !== void 0 && genMs > 0 && outputTokens > 0 ? outputTokens / (genMs / 1e3) : void 0;
1236
+ return {
1237
+ model,
1238
+ hour: hourStamp(endTime),
1239
+ ttftMs,
1240
+ ...tps === void 0 || !Number.isFinite(tps) || tps <= 0 ? {} : { tps },
1241
+ estimated: state.requestTime === void 0,
1242
+ ...latencyMs === void 0 ? {} : { latencyMs }
1243
+ };
1244
+ }
1163
1245
  /** Accumulate one ModelUsage into another (merge step of the incremental aggregator). */
1164
1246
  function mergeUsageInto(acc, cell) {
1165
1247
  acc.calls += cell.calls;
@@ -1171,6 +1253,21 @@ function mergeUsageInto(acc, cell) {
1171
1253
  acc.officialCalls += cell.officialCalls;
1172
1254
  acc.officialCost += cell.officialCost;
1173
1255
  }
1256
+ /** 均值(数组非空时调用;空数组按 0 兜底)。 */
1257
+ function mean(values) {
1258
+ if (values.length === 0) return 0;
1259
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
1260
+ }
1261
+ /** 分位数(0..1):先拷贝排序,再线性插值;空数组返回 0。 */
1262
+ function percentile(values, p) {
1263
+ if (values.length === 0) return 0;
1264
+ const sorted = [...values].sort((a, b) => a - b);
1265
+ const idx = (sorted.length - 1) * p;
1266
+ const lo = Math.floor(idx);
1267
+ const hi = Math.ceil(idx);
1268
+ if (lo === hi) return sorted[lo];
1269
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
1270
+ }
1174
1271
  /**
1175
1272
  * Create the incremental usage aggregator.
1176
1273
  * @param persistence - the session persistence service.
@@ -1245,6 +1342,8 @@ function createUsageAggregator(persistence, options = {}) {
1245
1342
  inputCost: 0,
1246
1343
  outputCost: 0
1247
1344
  };
1345
+ const perfModel = /* @__PURE__ */ new Map();
1346
+ const perfHour = /* @__PURE__ */ new Map();
1248
1347
  for (const { meta, fold } of folds) {
1249
1348
  const sessionId = String(meta.id);
1250
1349
  mergeUsageInto(total, fold.total);
@@ -1256,6 +1355,32 @@ function createUsageAggregator(persistence, options = {}) {
1256
1355
  for (const [day, cell] of fold.byDay) mergeUsageInto(usageCell(byDay, day), cell);
1257
1356
  for (const [day, models] of fold.byDayModels) for (const [modelKey, cell] of models) mergeUsageInto(modelDayCell(byDayModels, day, modelKey), cell);
1258
1357
  for (const [modelKey, count] of fold.planCalls) planCalls.set(modelKey, (planCalls.get(modelKey) ?? 0) + count);
1358
+ for (const sample of fold.perf) {
1359
+ let modelAccum = perfModel.get(sample.model);
1360
+ if (modelAccum === void 0) {
1361
+ modelAccum = {
1362
+ ttfts: [],
1363
+ tps: [],
1364
+ latencies: [],
1365
+ estimated: 0
1366
+ };
1367
+ perfModel.set(sample.model, modelAccum);
1368
+ }
1369
+ modelAccum.ttfts.push(sample.ttftMs);
1370
+ if (sample.tps !== void 0) modelAccum.tps.push(sample.tps);
1371
+ if (sample.latencyMs !== void 0) modelAccum.latencies.push(sample.latencyMs);
1372
+ if (sample.estimated) modelAccum.estimated += 1;
1373
+ let hourAccum = perfHour.get(sample.hour);
1374
+ if (hourAccum === void 0) {
1375
+ hourAccum = {
1376
+ ttfts: [],
1377
+ tps: []
1378
+ };
1379
+ perfHour.set(sample.hour, hourAccum);
1380
+ }
1381
+ hourAccum.ttfts.push(sample.ttftMs);
1382
+ if (sample.tps !== void 0) hourAccum.tps.push(sample.tps);
1383
+ }
1259
1384
  for (const row of fold.turns) turnRows.push({
1260
1385
  sessionId,
1261
1386
  ...row
@@ -1297,6 +1422,22 @@ function createUsageAggregator(persistence, options = {}) {
1297
1422
  return record;
1298
1423
  };
1299
1424
  const toModelDayRecord = (map) => Object.fromEntries([...map].map(([day, models]) => [day, Object.fromEntries(models)]));
1425
+ const perf = perfModel.size === 0 ? void 0 : {
1426
+ byModel: Object.fromEntries([...perfModel].map(([model, acc]) => [model, {
1427
+ samples: acc.ttfts.length,
1428
+ ttftAvg: mean(acc.ttfts),
1429
+ ttftP50: percentile(acc.ttfts, .5),
1430
+ ttftP90: percentile(acc.ttfts, .9),
1431
+ ...acc.tps.length === 0 ? {} : { tpsAvg: mean(acc.tps) },
1432
+ latencyAvg: acc.latencies.length === 0 ? 0 : mean(acc.latencies),
1433
+ estimatedSamples: acc.estimated
1434
+ }])),
1435
+ byHour: Object.fromEntries([...perfHour].map(([hour, acc]) => [hour, {
1436
+ samples: acc.ttfts.length,
1437
+ ttftAvg: mean(acc.ttfts),
1438
+ ...acc.tps.length === 0 ? {} : { tpsAvg: mean(acc.tps) }
1439
+ }]))
1440
+ };
1300
1441
  lastDoc = {
1301
1442
  version: 3,
1302
1443
  updatedAt: now,
@@ -1308,6 +1449,7 @@ function createUsageAggregator(persistence, options = {}) {
1308
1449
  bySession: sessionRows.slice(0, 100),
1309
1450
  byTurn: turnRows.slice(0, 200),
1310
1451
  byWorkspace: workspaces.slice(0, 100),
1452
+ ...perf === void 0 ? {} : { perf },
1311
1453
  byRole: (() => {
1312
1454
  const chars = roles.userChars + roles.toolChars;
1313
1455
  const userShare = chars > 0 ? roles.userChars / chars : .5;
@@ -1921,7 +2063,7 @@ const EMPTY_SUBSCRIPTION_KEYS = {
1921
2063
  /** 订阅类 provider 的显示名(未命中的回退为 id 本身)。 */
1922
2064
  const SUBSCRIPTION_DISPLAY_NAMES = {
1923
2065
  "kimi-coding": "Kimi For Coding",
1924
- "zai-coding-cn": "Z.ai Coding Plan",
2066
+ "zai-coding-cn": "Z.ai Coding Plan(国内)",
1925
2067
  "zai-coding": "Z.ai Coding Plan",
1926
2068
  "opencode": "OpenCode Plan",
1927
2069
  "opencode-go": "OpenCode Go",
@@ -65,6 +65,8 @@ export declare function emptyUsage(): ModelUsage;
65
65
  export declare function foldUsage(acc: ModelUsage, usage: TokenUsage, key: string, subscription: boolean, timeMs: number, official?: boolean): void;
66
66
  /** Local-time date stamp (the host runs in the user's timezone). */
67
67
  export declare function dayStamp(time: number): string;
68
+ /** Local-time hour stamp `YYYY-MM-DDTHH` — the performance series bucket key. */
69
+ export declare function hourStamp(time: number): string;
68
70
  /** cwd 未知时工作区聚合的占位名(UI 显示 em dash,保持语言无关)。 */
69
71
  export declare const UNKNOWN_WORKSPACE_NAME = "\u2014";
70
72
  /** 工作区名:取 cwd 的末级目录名;无 cwd 时返回 {@link UNKNOWN_WORKSPACE_NAME}。 */
@@ -98,6 +100,13 @@ export interface UsageStatsDocument {
98
100
  * 属估算口径,UI 需标注)。旧快照可能缺失。
99
101
  */
100
102
  byRole?: RoleCost;
103
+ /**
104
+ * 性能指标(TTFT / 生成速度 / 总延迟)按模型与按小时聚合;旧快照可能缺失。
105
+ * 口径:TTFT = request/header → 首个内容 chunk;生成速度 = 输出 token ÷ 生成时长;
106
+ * 总延迟 = request/header → assistant/message。工具续写步骤无独立请求头,
107
+ * 以 step/start 为起点估算并计 estimated。
108
+ */
109
+ perf?: PerfStats;
101
110
  }
102
111
  /** 按角色费用归因:user / tool 为输入成本的启发式摊分,assistant 为输出成本实测。 */
103
112
  export interface RoleCost {
@@ -105,6 +114,40 @@ export interface RoleCost {
105
114
  assistant: number;
106
115
  tool: number;
107
116
  }
117
+ /**
118
+ * 性能指标(TTFT / 生成速度 / 总延迟):按模型与按小时聚合,供「性能」面板渲染。
119
+ * 旧快照(无 perf 字段)缺失时客户端按无数据兜底。
120
+ */
121
+ export interface PerfStats {
122
+ /** 按模型聚合(键 = 计费目录键;未收录模型原样保留)。 */
123
+ byModel: Record<string, ModelPerf>;
124
+ /** 按小时聚合(键 = {@link hourStamp},北京时间)。 */
125
+ byHour: Record<string, HourPerf>;
126
+ }
127
+ /** 一个模型的性能统计:首字延时均值 / P50 / P90、生成速度均值、总延迟均值。 */
128
+ export interface ModelPerf {
129
+ /** 有效性能样本数(有可测 TTFT 的调用;不含损毁样本)。 */
130
+ samples: number;
131
+ /** 平均首字延时(毫秒)。 */
132
+ ttftAvg: number;
133
+ /** 首字延时 P50(毫秒)。 */
134
+ ttftP50: number;
135
+ /** 首字延时 P90(毫秒)。 */
136
+ ttftP90: number;
137
+ /** 平均生成速度(tokens/s);生成了有效输出且时长可测时存在。 */
138
+ tpsAvg?: number;
139
+ /** 平均总延迟(首次请求 → 响应完成,毫秒)。 */
140
+ latencyAvg: number;
141
+ /** 以 step/start 估算的样本数(工具续写步骤无独立 request/header)。 */
142
+ estimatedSamples: number;
143
+ }
144
+ /** 一个小时的性能统计(键 = {@link hourStamp})。 */
145
+ export interface HourPerf {
146
+ samples: number;
147
+ ttftAvg: number;
148
+ /** 平均生成速度(tokens/s);该小时无可测生成窗口时缺失。 */
149
+ tpsAvg?: number;
150
+ }
108
151
  /** 会话明细行:仪表盘「会话明细」面板的数据源。 */
109
152
  export interface SessionUsageRow {
110
153
  /** 会话 id(字符串形式)。 */
@@ -156,6 +199,21 @@ export declare const SESSION_ROW_LIMIT = 100;
156
199
  export declare const TURN_ROW_LIMIT = 200;
157
200
  /** 聚合文档的短 TTL(毫秒):合并密集轮询,TTL 内直接复用上次的合并结果。 */
158
201
  export declare const AGGREGATE_TTL_MS = 5000;
202
+ /** 单步性能样本(foldSession 的折叠产物;跨会话合并时按模型/小时再聚合)。 */
203
+ export interface PerfSample {
204
+ /** 计费目录键(模型;未收录模型原样保留)。 */
205
+ model: string;
206
+ /** 北京时间小时戳({@link hourStamp})——性能曲线的时间桶键。 */
207
+ hour: string;
208
+ /** 首字延时(毫秒);无效样本(超出 sane 上限)不入样本集。 */
209
+ ttftMs: number;
210
+ /** 生成速度(tokens/s);无有效生成窗口或无输出时缺失。 */
211
+ tps?: number;
212
+ /** 总延迟(首次请求 → 响应完成,毫秒);只测到内容但完成时刻优先于起点时缺失。 */
213
+ latencyMs?: number;
214
+ /** 无独立 request/header,以 step/start 起算(工具续写步骤)。 */
215
+ estimated: boolean;
216
+ }
159
217
  /** One persisted session's folded usage plus drill-down metadata. */
160
218
  interface SessionFold {
161
219
  total: ModelUsage;
@@ -166,6 +224,8 @@ interface SessionFold {
166
224
  planCalls: Map<string, number>;
167
225
  /** 每轮费用明细(按轮次号升序,不含 sessionId);sessionId 在合并时补齐。 */
168
226
  turns: SessionTurnRow[];
227
+ /** 性能样本(有可测 TTFT 的调用,按事件次序折叠)。 */
228
+ perf: PerfSample[];
169
229
  /** 角色归因中间量:消息文本长度(user/tool)与输入/输出成本实测拆分。 */
170
230
  roles: RoleFold;
171
231
  /** 日志里最新的 session/title 文本(无标题事件时 undefined)。 */
@@ -15,11 +15,31 @@
15
15
  */
16
16
  /** Plan type: code = subscription + quota, token = per-token usage. */
17
17
  export type PlanType = 'code' | 'token';
18
+ /**
19
+ * 订阅档位知识(自动识别的「档位月费 + 周期额度口径」):供订阅卡片在
20
+ * 厂商官方未提供实时额度接口时展示参考口径。currency 为原生币种;费用与
21
+ * 额度按官方订阅周期(天/周/月)计量时,periodDays 表述该重置周期。
22
+ */
23
+ export interface PlanTier {
24
+ /** 档位月费(原生币种值)。 */
25
+ amount: number;
26
+ currency: 'CNY' | 'USD';
27
+ /** 周期额度口径:每 periodDays 天重置的一个额度窗。 */
28
+ periodDays: number;
29
+ /** 周期请求额度(若有)。 */
30
+ requests?: number;
31
+ /** 周期 token 额度(若有)。 */
32
+ tokens?: number;
33
+ /** 额度口径的人话描述(官方未公布精确额度时)。 */
34
+ label?: string;
35
+ }
18
36
  /** One plan row: provider id (after alias) → plan shape + optional subscription fee. */
19
37
  export interface PlanKnowledgeEntry {
20
38
  type: PlanType;
21
39
  /** 订阅月费(人民币元);code 计划用,计入「本月预计」。 */
22
40
  subscriptionCny?: number;
41
+ /** 自动识别的档位月费与周期额度口径(订阅卡片展示)。 */
42
+ tier?: PlanTier;
23
43
  }
24
44
  /**
25
45
  * 订阅/计划 provider id → plan 知识(引用 dsh-spend 的 code/token 双口径)。
@@ -30,6 +50,8 @@ export declare const PLAN_KNOWLEDGE: Readonly<Record<string, PlanKnowledgeEntry>
30
50
  export declare function planTypeOf(providerId: string): PlanType;
31
51
  /** 订阅月费(人民币元);非 code 或未配置时为 0。 */
32
52
  export declare function subscriptionCnyOf(providerId: string): number;
53
+ /** 自动识别的档位月费与周期额度口径(订阅卡片展示);无档位知识返回 undefined。 */
54
+ export declare function tierInfoOf(providerId: string): PlanTier | undefined;
33
55
  export interface FallbackRate {
34
56
  /** 归一化模型 id(计费键,与 catalogEntries 的 key 同口径)。 */
35
57
  key: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-ui-usage-billing",
3
3
  "description": "Usage billing dashboard for DeepSeek Harness: sidebar cost metrics plus a full dashboard modal, priced from a current multi-provider catalog with real usage aggregated from session logs.",
4
- "version": "0.9.2",
4
+ "version": "0.9.4",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },