opencode-usage-report 0.6.0 → 0.6.2

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.md CHANGED
@@ -302,6 +302,19 @@ so a failing check blocks the stage.
302
302
 
303
303
  ## Changelog
304
304
 
305
+ - **0.6.2** — OpenCode 2 context panel fixes: the V2 adapter now reads the
306
+ model id from the store's `model.id`, so the model lookup succeeds and the
307
+ cell grid, per-category percentages and compaction-headroom line render
308
+ again instead of `—`; the translated messages also carry the session id, and
309
+ the system-prompt capture anchors its freshness check to the turn's user
310
+ message — OpenCode 2 fires the capture hook just before the assistant
311
+ message is stamped, which previously marked every capture `(derived)`.
312
+ - **0.6.1** — Kimi Code adapter tracks the 2026-09 quota-model API change:
313
+ usage windows now come from the `usages.limit_5h` / `limit_7d` /
314
+ `limit_month_total` ratios (with a `limit_month_code` breakdown), and Extra
315
+ Usage is read from the camelCase `boosterWallet` (fixed-point `BOOSTER`
316
+ balance). The pre-2026-09 `usage` / `limits[]` / `totalQuota` /
317
+ `booster_wallet` shapes remain as fallbacks.
305
318
  - **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
306
319
  replaces the copy-pasted fetch/retry/error code; context estimates simplified;
307
320
  reset times render as `Wed, Sep 16`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -4,7 +4,7 @@ import type { AssistantMessage, Model } from "@opencode-ai/sdk/v2";
4
4
  import { For, Show, createMemo } from "solid-js";
5
5
  import { buildContextLines, type ContextTone } from "./format";
6
6
  import { collectContext } from "./collect";
7
- import { captureIsFresh } from "./system";
7
+ import { captureIsFresh, captureSince } from "./system";
8
8
  import type { ContextBreakdown, SystemCapture } from "./types";
9
9
  import { buildLines } from "../quota-lines";
10
10
  import type { ProviderReport } from "../types";
@@ -98,7 +98,7 @@ export function UsageDialog(props: UsageDialogProps) {
98
98
  sessionID: last.sessionID,
99
99
  providerID: last.providerID,
100
100
  modelID: last.modelID,
101
- since: last.time?.created ?? 0,
101
+ since: captureSince(messages(), last),
102
102
  });
103
103
  return ok ? capture : null;
104
104
  });
@@ -110,6 +110,42 @@ export function captureIsFresh(
110
110
  if (capture.modelID !== ref.modelID) return false;
111
111
  const capturedAt = nonNegative(capture.capturedAt);
112
112
  const since = nonNegative(ref.since);
113
- if (capturedAt === null || since === null) return false;
113
+ // A zero boundary means the caller had no usable timestamp — treat it as
114
+ // unusable rather than "anything is fresh" (epoch 0 is never a real turn).
115
+ if (capturedAt === null || since === null || since === 0) return false;
114
116
  return capturedAt >= since;
115
117
  }
118
+
119
+ function messageCreated(value: unknown): number | null {
120
+ if (!isRecord(value)) return null;
121
+ const time = isRecord(value.time) ? value.time : {};
122
+ const created = nonNegative(time.created);
123
+ return created !== null && created > 0 ? created : null;
124
+ }
125
+
126
+ /**
127
+ * The `since` boundary for `captureIsFresh`: the creation time of the user
128
+ * message that started the turn `reference` belongs to, falling back to the
129
+ * reference's own creation time. Returns `0` when neither carries a usable
130
+ * timestamp, which `captureIsFresh` rejects.
131
+ *
132
+ * OpenCode 2 assembles the request — firing the system-transform hook — just
133
+ * before the assistant message row gets its `time.created`, so a capture from
134
+ * the current turn is a few milliseconds *older* than the message and the old
135
+ * `capturedAt >= message.created` boundary rejected every fresh capture. The
136
+ * turn's user message predates request assembly under both OpenCode 1 and 2,
137
+ * so it is an ordering-stable boundary; a capture written for an earlier turn
138
+ * still sits before it and is still rejected.
139
+ */
140
+ export function captureSince(messages: readonly unknown[], reference: unknown): number {
141
+ const list = Array.isArray(messages) ? messages : [];
142
+ const index = list.indexOf(reference);
143
+ const start = index === -1 ? list.length : index;
144
+ for (let i = start - 1; i >= 0; i--) {
145
+ const message = list[i];
146
+ if (!isRecord(message) || message.role !== "user") continue;
147
+ const created = messageCreated(message);
148
+ if (created !== null) return created;
149
+ }
150
+ return messageCreated(reference) ?? 0;
151
+ }
@@ -65,12 +65,17 @@ function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
65
65
  return undefined;
66
66
  }
67
67
 
68
- function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
68
+ /** 0-100 percent from a quota entry's `used_ratio`, or null when it is absent/invalid. */
69
+ function ratioPercent(value: unknown): number | null {
69
70
  const ratio = toNumber(pick(value, "used_ratio", "usedRatio"));
71
+ return ratio === null ? null : ratioToPercent(ratio);
72
+ }
73
+
74
+ function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
70
75
  return {
71
76
  kind,
72
77
  label,
73
- usedPercent: ratio !== null ? ratioToPercent(ratio) : null,
78
+ usedPercent: ratioPercent(value),
74
79
  used: null,
75
80
  limit: null,
76
81
  remaining: null,
@@ -88,6 +93,64 @@ function detailWindow(kind: UsageWindow["kind"], label: string, detail: unknown)
88
93
  });
89
94
  }
90
95
 
96
+ /**
97
+ * Booster-wallet amounts are fixed-point integers in micro-cents
98
+ * (1_000_000 → 1 cent), matching the official kimi-code parser.
99
+ */
100
+ const FIXED_POINT_CENTS = 1_000_000;
101
+
102
+ function fixedPointToCents(value: unknown): number | null {
103
+ const raw = toNumber(value);
104
+ if (raw === null) return null;
105
+ const cents = raw / FIXED_POINT_CENTS;
106
+ if (cents > 0 && cents < 1) return 1;
107
+ return Math.round(cents);
108
+ }
109
+
110
+ function formatMoney(cents: number, currency: string): string {
111
+ return `$${(cents / 100).toFixed(2)} ${currency}`;
112
+ }
113
+
114
+ /** Currency for the wallet, taken from whichever money field carries one. */
115
+ function walletCurrency(wallet: unknown): string {
116
+ for (const key of ["monthlyChargeLimit", "monthlyUsed"]) {
117
+ const currency = pick(pick(wallet, key), "currency");
118
+ if (typeof currency === "string" && currency !== "") return currency;
119
+ }
120
+ return "USD";
121
+ }
122
+
123
+ /**
124
+ * Renders the Extra Usage ("booster") wallet into `extras`. The post-2026-09
125
+ * quota model sends `boosterWallet.balance` as a `BOOSTER` descriptor with
126
+ * fixed-point micro-cents; the pre-2026-09 wire sent a scalar `booster_wallet.balance`.
127
+ */
128
+ function parseBoosterWallet(body: unknown, extras: Record<string, string>): void {
129
+ const wallet = pick(body, "boosterWallet", "booster_wallet");
130
+ if (wallet === undefined) return;
131
+
132
+ const balance = pick(wallet, "balance");
133
+ const totalCents =
134
+ pick(balance, "type") === "BOOSTER" ? fixedPointToCents(pick(balance, "amount")) : null;
135
+ if (totalCents !== null && totalCents > 0) {
136
+ const left = fixedPointToCents(pick(balance, "amountLeft")) ?? 0;
137
+ extras["Booster wallet"] = formatMoney(left, walletCurrency(wallet));
138
+ } else if (typeof balance === "string" || typeof balance === "number") {
139
+ extras["Booster wallet"] = String(balance);
140
+ }
141
+
142
+ const monthlyUsed = pick(wallet, "monthlyUsed", "monthly_used");
143
+ const currency = pick(monthlyUsed, "currency");
144
+ const priceInCents = toNumber(pick(monthlyUsed, "priceInCents", "price_in_cents"));
145
+ if (
146
+ (typeof currency === "string" || typeof currency === "number") &&
147
+ String(currency) !== "" &&
148
+ priceInCents !== null
149
+ ) {
150
+ extras["Booster used this month"] = formatMoney(priceInCents, String(currency));
151
+ }
152
+ }
153
+
91
154
  function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
92
155
  const windows: UsageWindow[] = [];
93
156
  const extras: Record<string, string> = {};
@@ -96,9 +159,9 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
96
159
  const usage = pick(body, "usage");
97
160
  const limits = pick(body, "limits");
98
161
 
99
- // Count-based windows are authoritative; the `usages.limit_5h/limit_7d`
100
- // ratios are legacy fields the live API returns as stale zeros even while
101
- // real usage accrues, so they are only a fallback when counts are missing.
162
+ // Count-based windows are authoritative when present; the `usages.limit_*`
163
+ // ratios are the primary wire on the post-2026-09 quota model, where the
164
+ // absolute `usage`/`limits[]` rows were removed.
102
165
  const detail5h = findLimitDetail(limits, 300);
103
166
  if (detail5h !== undefined) {
104
167
  windows.push(detailWindow("5h", "5-hour", detail5h));
@@ -114,43 +177,36 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
114
177
  if (limit7d !== undefined) windows.push(ratioWindow("weekly", "Weekly", limit7d));
115
178
  }
116
179
 
180
+ // The monthly total joined the quota model; `totalQuota` is the legacy shape.
181
+ // `limit_month_code` is the code-typed share the official client shows as a
182
+ // breakdown under the monthly row.
183
+ const monthTotal = pick(usages, "limit_month_total");
184
+ if (monthTotal !== undefined) {
185
+ windows.push(ratioWindow("monthly", "Monthly", monthTotal));
186
+ const codePercent = ratioPercent(pick(usages, "limit_month_code"));
187
+ if (codePercent !== null) extras["Monthly code"] = `${codePercent}%`;
188
+ } else {
189
+ const totalQuota = pick(body, "totalQuota", "total_quota");
190
+ const quotaUsed = toNumber(pick(totalQuota, "used"));
191
+ if (totalQuota !== undefined && quotaUsed !== null && quotaUsed > 0) {
192
+ windows.push({
193
+ kind: "monthly",
194
+ label: "Monthly",
195
+ usedPercent: null,
196
+ used: quotaUsed,
197
+ limit: toNumber(pick(totalQuota, "limit")),
198
+ remaining: toNumber(pick(totalQuota, "remaining")),
199
+ resetsAt: toISODate(pick(totalQuota, "resetTime", "reset_time", "resetAt", "resetsAt")),
200
+ status: "frozen",
201
+ });
202
+ }
203
+ }
204
+
117
205
  const level = pick(pick(body, "user"), "membership");
118
206
  const membershipLevel = pick(level, "level");
119
207
  if (membershipLevel !== undefined) extras["Plan"] = String(membershipLevel);
120
208
 
121
- const wallet = pick(body, "booster_wallet");
122
-
123
- // `balance` is a wallet descriptor object on the live API; only render scalar values.
124
- const balance = pick(wallet, "balance");
125
- if (typeof balance === "string" || typeof balance === "number") {
126
- extras["Booster wallet"] = String(balance);
127
- }
128
-
129
- const monthlyUsed = pick(wallet, "monthlyUsed", "monthly_used");
130
- const currency = pick(monthlyUsed, "currency");
131
- const priceInCents = toNumber(pick(monthlyUsed, "priceInCents", "price_in_cents"));
132
- if (
133
- (typeof currency === "string" || typeof currency === "number") &&
134
- String(currency) !== "" &&
135
- priceInCents !== null
136
- ) {
137
- extras["Booster used this month"] = `$${(priceInCents / 100).toFixed(2)} ${String(currency)}`;
138
- }
139
-
140
- const totalQuota = pick(body, "totalQuota", "total_quota");
141
- const quotaUsed = toNumber(pick(totalQuota, "used"));
142
- if (totalQuota !== undefined && quotaUsed !== null && quotaUsed > 0) {
143
- windows.push({
144
- kind: "monthly",
145
- label: "Monthly",
146
- usedPercent: null,
147
- used: quotaUsed,
148
- limit: toNumber(pick(totalQuota, "limit")),
149
- remaining: toNumber(pick(totalQuota, "remaining")),
150
- resetsAt: toISODate(pick(totalQuota, "resetTime", "reset_time", "resetAt", "resetsAt")),
151
- status: "frozen",
152
- });
153
- }
209
+ parseBoosterWallet(body, extras);
154
210
 
155
211
  if (windows.length === 0) {
156
212
  throw new AdapterError(
package/src/v2-tui.ts CHANGED
@@ -62,6 +62,8 @@ export interface V2Route {
62
62
 
63
63
  export interface V2ModelRef {
64
64
  readonly providerID?: string;
65
+ /** The OpenCode 2 store spells the model id `id`; older mirrors used `modelID`. */
66
+ readonly id?: string;
65
67
  readonly modelID?: string;
66
68
  }
67
69
 
@@ -249,7 +251,10 @@ function createdAt(message: { readonly time?: { readonly created?: number } }):
249
251
  * messages carry a single `text` and an optional file list, so those are
250
252
  * synthesized into the same part shape.
251
253
  */
252
- function translateMessage(raw: unknown): {
254
+ function translateMessage(
255
+ raw: unknown,
256
+ sessionID: string,
257
+ ): {
253
258
  record: Record<string, unknown>;
254
259
  parts: Array<Record<string, unknown>>;
255
260
  } {
@@ -264,9 +269,12 @@ function translateMessage(raw: unknown): {
264
269
  return {
265
270
  record: {
266
271
  id,
272
+ sessionID,
267
273
  role: "assistant",
268
274
  providerID: safeString(model.providerID),
269
- modelID: safeString(model.modelID),
275
+ // The store sends `model.id` (e.g. `{ id: "k3", providerID, variant }`);
276
+ // `model.modelID` is kept as a fallback for older hosts and fixtures.
277
+ modelID: safeString(model.modelID) || safeString(model.id),
270
278
  time: createdAt(message),
271
279
  tokens: {
272
280
  input: finiteOrNull(tokens.input) ?? 0,
@@ -294,7 +302,7 @@ function translateMessage(raw: unknown): {
294
302
  }
295
303
  }
296
304
  return {
297
- record: { id, role: "user", time: createdAt(message), tokens: { ...ZERO_TOKENS } },
305
+ record: { id, sessionID, role: "user", time: createdAt(message), tokens: { ...ZERO_TOKENS } },
298
306
  parts,
299
307
  };
300
308
  }
@@ -362,7 +370,7 @@ export function createV2Api(ctx: V2Context): TuiPluginApi {
362
370
  const messages: Array<Record<string, unknown>> = [];
363
371
  partsById.clear();
364
372
  for (const raw of list) {
365
- const { record, parts } = translateMessage(raw);
373
+ const { record, parts } = translateMessage(raw, sessionID);
366
374
  if (typeof record.id !== "string" || record.id === "") continue;
367
375
  messages.push(record);
368
376
  partsById.set(record.id, parts);