@linxin666/dsh-usage 0.3.12 → 0.3.13

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 1a719cb72c95039ff27f0c54f11beacde523773c
6
- README.zh.md: ab3c925eb31290df2140d9870c2f9ae4abc5f6a4
5
+ README.md: 70d29c942f163ad74f32ee6bbf8405058aad81be
6
+ README.zh.md: 53a5a6c260417a348d873f85ee2d87b9a1fe8bd6
package/README.md CHANGED
@@ -10,7 +10,7 @@ The plugin runs a host-side service and a first-level settings section (使用
10
10
 
11
11
  - **Usage tab (用量)**: today's token totals per bucket (input / output / cache read / cache write, disjoint as the provider reports them) with per-provider and per-model breakdown, the last 30 days as a horizontal provider/model bar chart, and the balance of every configured provider. For the official DeepSeek family the tab also shows the current peak/off-peak pricing period (Beijing weekdays 09:00-12:00 and 14:00-18:00 are peak, billed at double) and today's estimated spend in CNY. The ledger folds live `session/event` streams (`request/header` route attribution plus `assistant/message` usage) into `$DSH_HOME/dsh-usage/usage-ledger.json` with day-based retention; counting starts when the plugin is first enabled.
12
12
  - **Plans tab (个人套餐)**: coding-plan quota windows for every configured provider that exposes one — used percent and reset time per window (Kimi For Coding 5h/week, GLM Coding Plan 5h/week, OpenCode Go rolling/weekly/monthly, MiniMax 5h/week, Codex / ChatGPT subscription 5h/week). Providers without a real plan or subscription (DeepSeek, ZenMux, Moonshot, OpenRouter, SiliconFlow) never appear on this tab; their balance shows on the usage tab instead.
13
- - **Pet linkage**: the pet renders a dedicated announcement bubble (own glass style, tone accent, mini quota meter) following the current session provider. Plan providers (Kimi, GLM, Codex subscription, ...) show their tightest percent window; the official DeepSeek family shows today's estimated spend with the current peak/off-peak period and the account balance. `bubbleMode` controls it: always (refreshed every poll with a poll-cadence TTL, so the bubble stays visible) / on change / off.
13
+ - **Pet linkage**: the pet renders a dedicated announcement bubble (own glass style, tone accent, mini quota meter) following the current session provider. Plan providers (Kimi, GLM, Codex subscription, ...) show their tightest percent window; the official DeepSeek family shows today's estimated spend with the current peak/off-peak period and the account balance. When the session provider has no announceable probe fact (a relay station or local runtime without an adapter, failing probes, percent-less windows), the bubble falls back to that provider's live usage today (tokens and call count); with neither fact nor usage it stays silent. `bubbleMode` controls it: always (refreshed every poll with a poll-cadence TTL, so the bubble stays visible) / on change / off.
14
14
  - Probes run entirely host-side on a poll cycle (default 60 s, manual refresh button); API keys are resolved through the harness credential seam (`llm-pi-ai` records, `apiKeyEnv` references) and never reach the browser.
15
15
 
16
16
  Supported balance endpoints: DeepSeek (the official live route `deepseek-official` and the catalog alias `deepseek` both resolve), Moonshot (CN/international), OpenRouter, SiliconFlow (CN/international), ZenMux. Supported plan endpoints: Kimi For Coding, GLM Coding Plan (CN via open.bigmodel.cn, international via api.z.ai), OpenCode Go, MiniMax, Codex / ChatGPT subscription (OAuth access token from the pi-ai grant; a stale token shows an error until the harness next refreshes it). Providers without a programmatic endpoint (Qwen token plans, OpenCode Zen PAYG, Anthropic, OpenAI) are listed without facts.
package/README.zh.md CHANGED
@@ -10,7 +10,7 @@ dsh Web GUI 的使用统计插件:多 provider 余额与编程套餐用量检
10
10
 
11
11
  - **用量页签**:今日 token 分桶合计(输入 / 输出 / 缓存读 / 缓存写,按 provider 上报口径互不相加),分 provider 与模型细分,近 30 天以「提供方-模型」水平条形图展示,以及所有已配置 provider 的余额。对 DeepSeek 官方路由,页签还展示当前峰谷计价时段(北京时间工作日 09:00-12:00、14:00-18:00 为高峰,按双倍计费)与今日消费估算(CNY)。台账从 `session/event` 实时流折叠(`request/header` 归因路由 + `assistant/message` 用量),持久化到 `$DSH_HOME/dsh-usage/usage-ledger.json`,按本地日保留;统计自插件首次启用起计。
12
12
  - **个人套餐页签**:每个已配置且暴露套餐端点的 provider 的配额窗口——已用百分比与重置时间(Kimi For Coding 5 小时/每周、GLM 编程计划 5 小时/每周、OpenCode Go 滚动/每周/每月、MiniMax 5 小时/每周、Codex / ChatGPT 订阅 5 小时/每周)。没有真实套餐/订阅体系的厂商(DeepSeek、ZenMux、Moonshot、OpenRouter、SiliconFlow)不出现在此页签,其余额显示在用量页签。
13
- - **宠物联动**:宠物渲染一只专用公告气泡(独立玻璃样式、色调描边、微型配额计量条),跟随当前会话提供方。套餐类 provider(Kimi、GLM、Codex 订阅等)展示最紧的百分比窗口;DeepSeek 官方路由展示今日消费估算、当前峰谷时段与账户余额。`bubbleMode` 控制行为:常驻(每次轮询即刷新,TTL 随轮询周期走,气泡保持可见)/ 仅变化时 / 关闭。
13
+ - **宠物联动**:宠物渲染一只专用公告气泡(独立玻璃样式、色调描边、微型配额计量条),跟随当前会话提供方。套餐类 provider(Kimi、GLM、Codex 订阅等)展示最紧的百分比窗口;DeepSeek 官方路由展示今日消费估算、当前峰谷时段与账户余额。会话提供方没有可公告的探测事实时(无适配器的中转站或本地运行时、探测失败、无百分比窗口),气泡回落为该提供方今日的实时用量(tokens 与调用次数);事实与用量皆无时保持沉默。`bubbleMode` 控制行为:常驻(每次轮询即刷新,TTL 随轮询周期走,气泡保持可见)/ 仅变化时 / 关闭。
14
14
  - 探测完全在宿主侧按轮询周期执行(默认 60 秒,可手动刷新);API key 经宿主凭据缝解析(`llm-pi-ai` 记录、`apiKeyEnv` 引用),永不进入浏览器。
15
15
 
16
16
  支持的余额端点:DeepSeek(官方运行时路由 `deepseek-official` 与目录别名 `deepseek` 均可解析)、Moonshot(国内/国际)、OpenRouter、SiliconFlow(国内/国际)、ZenMux。支持的套餐端点:Kimi For Coding、GLM 编程计划(国内 open.bigmodel.cn、国际 api.z.ai)、OpenCode Go、MiniMax、Codex / ChatGPT 订阅(OAuth access token 取自 pi-ai grant;token 过期时显示错误行,宿主下次跑 Codex 请求自动刷新后恢复)。没有程序化端点的 provider(Qwen token 套餐、OpenCode Zen 按量、Anthropic、OpenAI)仅列出,不展示数据。
package/lib/client.js CHANGED
@@ -159,7 +159,7 @@ window.__ModuleLoader__.load({
159
159
  //#endregion
160
160
  //#region \0dsh-css:packages/dsh-usage/src/client/usage.module.css.mjs
161
161
  const css = ".cvtkAW_section{color:inherit;flex-direction:column;gap:16px;display:flex}.cvtkAW_header{justify-content:space-between;align-items:center;gap:12px;display:flex}.cvtkAW_currentProvider{opacity:.75;font-size:13px}.cvtkAW_refreshBtn{appearance:none;color:inherit;font:inherit;cursor:pointer;opacity:.85;background:0 0;border:1px solid;border-radius:8px;padding:4px 12px;font-size:12px;transition:opacity .12s,background-color .12s}.cvtkAW_refreshBtn:hover:not(:disabled){opacity:1;background:color-mix(in srgb, currentColor 8%, transparent)}.cvtkAW_refreshBtn:disabled{cursor:default;opacity:.5}.cvtkAW_refreshBtn:focus-visible{box-shadow:0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);outline:none}.cvtkAW_tabs{border-bottom:1px solid color-mix(in srgb, currentColor 14%, transparent);gap:4px;display:flex}.cvtkAW_tab{appearance:none;color:inherit;font:inherit;cursor:pointer;opacity:.65;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:6px 14px;font-size:13px}.cvtkAW_tab:hover{opacity:.9}.cvtkAW_tab:focus-visible{box-shadow:0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);outline:none}.cvtkAW_tabActive{opacity:1;border-bottom-color:currentColor;font-weight:600}.cvtkAW_card{border:1px solid color-mix(in srgb, currentColor 14%, transparent);border-radius:12px;flex-direction:column;gap:10px;padding:14px 16px;display:flex}.cvtkAW_cardTitle{letter-spacing:.04em;text-transform:uppercase;opacity:.6;font-size:12px;font-weight:600}.cvtkAW_statRow{flex-wrap:wrap;gap:18px;display:flex}.cvtkAW_stat{flex-direction:column;gap:2px;display:flex}.cvtkAW_statValue{font-variant-numeric:tabular-nums;font-size:18px;font-weight:600}.cvtkAW_statLabel{opacity:.6;font-size:11px}.cvtkAW_providerRow{border-top:1px solid color-mix(in srgb, currentColor 8%, transparent);justify-content:space-between;align-items:center;gap:12px;padding:6px 0;font-size:13px;display:flex}.cvtkAW_providerRow:first-of-type{border-top:none}.cvtkAW_providerName{align-items:center;gap:8px;min-width:0;display:flex}.cvtkAW_providerTokens{font-variant-numeric:tabular-nums;opacity:.75;white-space:nowrap}.cvtkAW_providerBalance{font-variant-numeric:tabular-nums;white-space:nowrap;font-weight:600}.cvtkAW_currentBadge{border:1px solid color-mix(in srgb, currentColor 35%, transparent);opacity:.8;border-radius:999px;flex:none;padding:1px 7px;font-size:10px;font-weight:600}.cvtkAW_chart{flex-direction:column;gap:12px;display:flex}.cvtkAW_chartProvider{flex-direction:column;gap:4px;display:flex}.cvtkAW_chartHead{justify-content:space-between;align-items:baseline;gap:10px;font-size:13px;display:flex}.cvtkAW_chartTokens{font-variant-numeric:tabular-nums;opacity:.65;white-space:nowrap;font-size:11px}.cvtkAW_chartBar{background:color-mix(in srgb, currentColor 8%, transparent);border-radius:999px;height:8px;display:block;overflow:hidden}.cvtkAW_chartFill{background:color-mix(in srgb, currentColor 55%, transparent);border-radius:999px;height:100%;transition:width .3s;display:block}.cvtkAW_chartModel{opacity:.8;grid-template-columns:minmax(80px,180px) 1fr auto;align-items:center;gap:8px;padding-left:14px;font-size:11px;display:grid}.cvtkAW_chartModelName{text-overflow:ellipsis;white-space:nowrap;opacity:.8;overflow:hidden}.cvtkAW_chartModelBar{background:color-mix(in srgb, currentColor 6%, transparent);border-radius:999px;height:4px;display:block;overflow:hidden}.cvtkAW_chartModelFill{background:color-mix(in srgb, currentColor 35%, transparent);border-radius:999px;height:100%;transition:width .3s;display:block}.cvtkAW_trendAxis{opacity:.5;font-variant-numeric:tabular-nums;justify-content:space-between;font-size:10px;display:flex}.cvtkAW_muted{opacity:.6;font-size:12px}.cvtkAW_errorLine{opacity:.75;font-size:12px}.cvtkAW_planCard{flex-direction:column;gap:8px;display:flex}.cvtkAW_planHead{justify-content:space-between;align-items:baseline;gap:10px;display:flex}.cvtkAW_planName{font-size:14px;font-weight:600}.cvtkAW_windowRow{flex-direction:column;gap:4px;display:flex}.cvtkAW_windowLabel{opacity:.8;font-variant-numeric:tabular-nums;justify-content:space-between;font-size:12px;display:flex}.cvtkAW_bar{background:color-mix(in srgb, currentColor 10%, transparent);border-radius:999px;height:6px;overflow:hidden}.cvtkAW_barFill{background:color-mix(in srgb, currentColor 55%, transparent);border-radius:999px;height:100%;transition:width .3s}.cvtkAW_barWarn{background:#d97706}.cvtkAW_barLow{background:#dc2626}.cvtkAW_resetLine{opacity:.55;font-variant-numeric:tabular-nums;font-size:11px}.cvtkAW_settingsGrid{flex-wrap:wrap;align-items:center;gap:16px;display:flex}.cvtkAW_settingItem{align-items:center;gap:8px;font-size:13px;display:flex}.cvtkAW_settingItem input[type=checkbox]{accent-color:currentColor}.cvtkAW_settingItem input[type=number]{border:1px solid color-mix(in srgb, currentColor 25%, transparent);width:90px;color:inherit;font:inherit;background:0 0;border-radius:6px;padding:3px 8px;font-size:13px}.cvtkAW_settingItem select{border:1px solid color-mix(in srgb, currentColor 25%, transparent);color:inherit;font:inherit;background:0 0;border-radius:6px;padding:3px 8px;font-size:13px}.cvtkAW_settingItem input:focus-visible,.cvtkAW_settingItem select:focus-visible{box-shadow:0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);outline:none}";
162
- const tagId = "@linxin666/dsh-usage/usage.module.css";
162
+ const tagId = "@linxin666/dsh-usage/packages/dsh-usage/src/client/usage.module.css";
163
163
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
164
164
  const tag = document.createElement("style");
165
165
  tag.dataset.plugin = "@linxin666/dsh-usage";
package/lib/index.js CHANGED
@@ -921,6 +921,41 @@ function buildAnnouncement(snapshot, context) {
921
921
  tone: planTone(window.percent)
922
922
  };
923
923
  }
924
+ /**
925
+ * Compact token-count display for the usage fallback bubble: `9805`,
926
+ * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
927
+ * line this service speaks), so the magnitudes follow the zh convention.
928
+ */
929
+ function formatTokens(count) {
930
+ const compact = (value) => {
931
+ const rounded = Math.round(value * 10) / 10;
932
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
933
+ };
934
+ if (count >= 1e8) return compact(count / 1e8) + "亿";
935
+ if (count >= 1e4) return compact(count / 1e4) + "万";
936
+ return String(count);
937
+ }
938
+ /**
939
+ * The usage fallback for the session's provider: today's token consumption
940
+ * and call count, the one fact the ledger owns for every provider. Providers
941
+ * without a readable balance/plan endpoint (relay stations, local runtimes,
942
+ * token-plan vendors — and probeable providers whose probes are failing)
943
+ * would otherwise leave the pet bubble permanently silent even while their
944
+ * sessions run. Undefined when the provider has no usage today: a bubble
945
+ * about nothing is noise, not information. Pure; the payload satisfies the
946
+ * pet's `parseAnnouncement` contract.
947
+ */
948
+ function buildLedgerAnnouncement(input) {
949
+ const total = totalTokens(input.totals);
950
+ if (input.totals.calls <= 0 || total <= 0) return void 0;
951
+ return {
952
+ kind: "cost",
953
+ title: input.displayName,
954
+ amount: `今日 ${formatTokens(total)} tokens`,
955
+ note: `${input.totals.calls} 次调用`,
956
+ tone: "ok"
957
+ };
958
+ }
924
959
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
925
960
  function service(ctx, name) {
926
961
  try {
@@ -1088,6 +1123,18 @@ var UsageService = class {
1088
1123
  for (const row of this.daySummary(localDateKey(Date.now())).providers) if (adapterFor(row.provider) === family) cost += row.totals.cost;
1089
1124
  return cost;
1090
1125
  }
1126
+ /** Today's ledger totals for one provider route (the exact id, not the family). */
1127
+ providerUsageToday(provider) {
1128
+ return this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)?.totals ?? emptyTotals();
1129
+ }
1130
+ /**
1131
+ * The display name for a route key: the LLM runtime's first, then the
1132
+ * adapter's, then the id itself — the snapshot may not exist for
1133
+ * adapter-less routes, yet the bubble still needs a legible title.
1134
+ */
1135
+ routeDisplayName(provider) {
1136
+ return this.listProviderRoutes().find((entry) => entry.id === provider)?.displayName || adapterFor(provider)?.displayName || provider;
1137
+ }
1091
1138
  onSessionEvent(session, event) {
1092
1139
  try {
1093
1140
  if (event.type === "request/header") {
@@ -1391,9 +1438,12 @@ var UsageService = class {
1391
1438
  * cycle, so a TTL of two cycles + margin keeps the bubble continuous
1392
1439
  * across polls; the pet contract caps the ceiling). A route id the
1393
1440
  * catalogs spell differently than the snapshot keys falls back to its
1394
- * adapter family's snapshot. Fully guarded: a malformed snapshot or a
1395
- * failing pet service must never break the poll loop, and a disposed
1396
- * service never announces.
1441
+ * adapter family's snapshot. When the provider has no announceable probe
1442
+ * fact (no adapter, failing probes, percent-less windows) the bubble falls
1443
+ * back to the provider's today ledger usage, and with neither fact nor
1444
+ * usage it stays silent. Fully guarded: a malformed snapshot or a failing
1445
+ * pet service must never break the poll loop, and a disposed service never
1446
+ * announces.
1397
1447
  */
1398
1448
  announceCurrent() {
1399
1449
  if (this.disposed || this.options.bubbleMode === "off") return;
@@ -1420,10 +1470,14 @@ var UsageService = class {
1420
1470
  }
1421
1471
  }
1422
1472
  }
1423
- if (snapshot === void 0) return;
1424
- const announcement = buildAnnouncement(snapshot, {
1473
+ const displayName = snapshot?.displayName ?? this.routeDisplayName(provider);
1474
+ let announcement = snapshot !== void 0 ? buildAnnouncement(snapshot, {
1425
1475
  todayCost: this.familyCostToday(provider),
1426
1476
  peak: deepseekPeriodAt(Date.now()).peak
1477
+ }) : void 0;
1478
+ if (announcement === void 0) announcement = buildLedgerAnnouncement({
1479
+ displayName,
1480
+ totals: this.providerUsageToday(provider)
1427
1481
  });
1428
1482
  if (announcement === void 0) return;
1429
1483
  const signature = JSON.stringify(announcement);
@@ -7,7 +7,7 @@
7
7
  * @module @linxin666/dsh-usage/host/usage-service
8
8
  */
9
9
  import type { Context } from '@deepseek-ai/cordis';
10
- import type { ProviderSnapshotView, UsageOverviewView } from '../core/types.ts';
10
+ import type { ProviderSnapshotView, UsageOverviewView, UsageTokenTotals } from '../core/types.ts';
11
11
  /** Source tag the plugin stamps onto pet announcements. */
12
12
  export declare const USAGE_ANNOUNCE_SOURCE = "dsh-usage";
13
13
  /** Poll-loop and announce options; re-applied live on settings change. */
@@ -38,6 +38,26 @@ export interface AnnounceContext {
38
38
  * percent window.
39
39
  */
40
40
  export declare function buildAnnouncement(snapshot: Pick<ProviderSnapshotView, 'displayName' | 'balance' | 'plan'>, context?: AnnounceContext): Record<string, unknown> | undefined;
41
+ /**
42
+ * Compact token-count display for the usage fallback bubble: `9805`,
43
+ * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
44
+ * line this service speaks), so the magnitudes follow the zh convention.
45
+ */
46
+ export declare function formatTokens(count: number): string;
47
+ /**
48
+ * The usage fallback for the session's provider: today's token consumption
49
+ * and call count, the one fact the ledger owns for every provider. Providers
50
+ * without a readable balance/plan endpoint (relay stations, local runtimes,
51
+ * token-plan vendors — and probeable providers whose probes are failing)
52
+ * would otherwise leave the pet bubble permanently silent even while their
53
+ * sessions run. Undefined when the provider has no usage today: a bubble
54
+ * about nothing is noise, not information. Pure; the payload satisfies the
55
+ * pet's `parseAnnouncement` contract.
56
+ */
57
+ export declare function buildLedgerAnnouncement(input: {
58
+ displayName: string;
59
+ totals: UsageTokenTotals;
60
+ }): Record<string, unknown> | undefined;
41
61
  export declare class UsageService {
42
62
  private readonly ctx;
43
63
  private options;
@@ -83,6 +103,14 @@ export declare class UsageService {
83
103
  private daySummary;
84
104
  /** Today's ledger spend for one provider's adapter family (0 when unpriced). */
85
105
  private familyCostToday;
106
+ /** Today's ledger totals for one provider route (the exact id, not the family). */
107
+ private providerUsageToday;
108
+ /**
109
+ * The display name for a route key: the LLM runtime's first, then the
110
+ * adapter's, then the id itself — the snapshot may not exist for
111
+ * adapter-less routes, yet the bubble still needs a legible title.
112
+ */
113
+ private routeDisplayName;
86
114
  private onSessionEvent;
87
115
  /**
88
116
  * Normalize a provider TokenUsage into the ledger bucket (one call). The
@@ -135,9 +163,12 @@ export declare class UsageService {
135
163
  * cycle, so a TTL of two cycles + margin keeps the bubble continuous
136
164
  * across polls; the pet contract caps the ceiling). A route id the
137
165
  * catalogs spell differently than the snapshot keys falls back to its
138
- * adapter family's snapshot. Fully guarded: a malformed snapshot or a
139
- * failing pet service must never break the poll loop, and a disposed
140
- * service never announces.
166
+ * adapter family's snapshot. When the provider has no announceable probe
167
+ * fact (no adapter, failing probes, percent-less windows) the bubble falls
168
+ * back to the provider's today ledger usage, and with neither fact nor
169
+ * usage it stays silent. Fully guarded: a malformed snapshot or a failing
170
+ * pet service must never break the poll loop, and a disposed service never
171
+ * announces.
141
172
  */
142
173
  private announceCurrent;
143
174
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-usage",
3
3
  "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a dedicated pet bubble for the current provider",
4
- "version": "0.3.12",
4
+ "version": "0.3.13",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -66,7 +66,7 @@
66
66
  "@deepseek-ai/dsh-llm": "^0.1.2-alpha.4",
67
67
  "@deepseek-ai/dsh-session": "^0.1.2-alpha.4",
68
68
  "@deepseek-ai/dsh-settings": "^0.1.2-alpha.4",
69
- "@linxin666/dsh-pet": "0.3.12",
69
+ "@linxin666/dsh-pet": "0.3.13",
70
70
  "@testing-library/dom": "^10.4.1",
71
71
  "@testing-library/react": "^16.3.2",
72
72
  "@types/node": "^22.20.0",
@@ -17,7 +17,7 @@ import { dshHome } from '../dsh-home.ts'
17
17
  import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
18
18
  import type { BalanceParse, PlanParse } from '../core/adapters.ts'
19
19
  import { deepseekModelSpend, deepseekPeriodAt } from '../core/pricing.ts'
20
- import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays } from '../core/ledger.ts'
20
+ import { createLedgerDocument, deserializeLedger, foldUsage, ledgerDayKeys, localDateKey, pruneLedger, summarizeDays, totalTokens } from '../core/ledger.ts'
21
21
  import type { BalanceView, CredentialKind, PlanView, ProviderSnapshotState, ProviderSnapshotView, UsageLedgerDocument, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
22
22
  import { emptyTotals } from '../core/types.ts'
23
23
 
@@ -132,6 +132,43 @@ export function buildAnnouncement(
132
132
  }
133
133
  }
134
134
 
135
+ /**
136
+ * Compact token-count display for the usage fallback bubble: `9805`,
137
+ * `8.7万`, `1.2亿`. The host-authored bubble copy is zh (like every other
138
+ * line this service speaks), so the magnitudes follow the zh convention.
139
+ */
140
+ export function formatTokens(count: number): string {
141
+ const compact = (value: number): string => {
142
+ const rounded = Math.round(value * 10) / 10
143
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
144
+ }
145
+ if (count >= 1e8) return compact(count / 1e8) + '亿'
146
+ if (count >= 1e4) return compact(count / 1e4) + '万'
147
+ return String(count)
148
+ }
149
+
150
+ /**
151
+ * The usage fallback for the session's provider: today's token consumption
152
+ * and call count, the one fact the ledger owns for every provider. Providers
153
+ * without a readable balance/plan endpoint (relay stations, local runtimes,
154
+ * token-plan vendors — and probeable providers whose probes are failing)
155
+ * would otherwise leave the pet bubble permanently silent even while their
156
+ * sessions run. Undefined when the provider has no usage today: a bubble
157
+ * about nothing is noise, not information. Pure; the payload satisfies the
158
+ * pet's `parseAnnouncement` contract.
159
+ */
160
+ export function buildLedgerAnnouncement(input: { displayName: string; totals: UsageTokenTotals }): Record<string, unknown> | undefined {
161
+ const total = totalTokens(input.totals)
162
+ if (input.totals.calls <= 0 || total <= 0) return undefined
163
+ return {
164
+ kind: 'cost',
165
+ title: input.displayName,
166
+ amount: `今日 ${formatTokens(total)} tokens`,
167
+ note: `${input.totals.calls} 次调用`,
168
+ tone: 'ok',
169
+ }
170
+ }
171
+
135
172
  /** Best-effort typed service read: absent services resolve to undefined at runtime. */
136
173
  function service<T>(ctx: Context, name: string): T | undefined {
137
174
  try {
@@ -310,6 +347,22 @@ export class UsageService {
310
347
  return cost
311
348
  }
312
349
 
350
+ /** Today's ledger totals for one provider route (the exact id, not the family). */
351
+ private providerUsageToday(provider: string): UsageTokenTotals {
352
+ const row = this.daySummary(localDateKey(Date.now())).providers.find((entry) => entry.provider === provider)
353
+ return row?.totals ?? emptyTotals()
354
+ }
355
+
356
+ /**
357
+ * The display name for a route key: the LLM runtime's first, then the
358
+ * adapter's, then the id itself — the snapshot may not exist for
359
+ * adapter-less routes, yet the bubble still needs a legible title.
360
+ */
361
+ private routeDisplayName(provider: string): string {
362
+ const route = this.listProviderRoutes().find((entry) => entry.id === provider)
363
+ return route?.displayName || adapterFor(provider)?.displayName || provider
364
+ }
365
+
313
366
  // ------------------------------------------------------------------
314
367
  // Session usage fold
315
368
  // ------------------------------------------------------------------
@@ -651,9 +704,12 @@ export class UsageService {
651
704
  * cycle, so a TTL of two cycles + margin keeps the bubble continuous
652
705
  * across polls; the pet contract caps the ceiling). A route id the
653
706
  * catalogs spell differently than the snapshot keys falls back to its
654
- * adapter family's snapshot. Fully guarded: a malformed snapshot or a
655
- * failing pet service must never break the poll loop, and a disposed
656
- * service never announces.
707
+ * adapter family's snapshot. When the provider has no announceable probe
708
+ * fact (no adapter, failing probes, percent-less windows) the bubble falls
709
+ * back to the provider's today ledger usage, and with neither fact nor
710
+ * usage it stays silent. Fully guarded: a malformed snapshot or a failing
711
+ * pet service must never break the poll loop, and a disposed service never
712
+ * announces.
657
713
  */
658
714
  private announceCurrent(): void {
659
715
  if (this.disposed || this.options.bubbleMode === 'off') return
@@ -680,11 +736,19 @@ export class UsageService {
680
736
  }
681
737
  }
682
738
  }
683
- if (snapshot === undefined) return
684
- const announcement = buildAnnouncement(snapshot, {
685
- todayCost: this.familyCostToday(provider),
686
- peak: deepseekPeriodAt(Date.now()).peak,
687
- })
739
+ const displayName = snapshot?.displayName ?? this.routeDisplayName(provider)
740
+ let announcement = snapshot !== undefined
741
+ ? buildAnnouncement(snapshot, {
742
+ todayCost: this.familyCostToday(provider),
743
+ peak: deepseekPeriodAt(Date.now()).peak,
744
+ })
745
+ : undefined
746
+ // No probeable fact for the session's provider (no adapter at all,
747
+ // failing probes, or percent-less windows): the bubble still follows
748
+ // the provider with the one fact the ledger owns — today's usage.
749
+ if (announcement === undefined) {
750
+ announcement = buildLedgerAnnouncement({ displayName, totals: this.providerUsageToday(provider) })
751
+ }
688
752
  if (announcement === undefined) return
689
753
  const signature = JSON.stringify(announcement)
690
754
  if (this.options.bubbleMode === 'change' && signature === this.lastSignature) return