sidekick-shared 0.28.1 → 0.28.3

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +29 -13
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +10 -1
  5. package/dist/codexQuota.js +17 -4
  6. package/dist/esm/browser.js +2 -0
  7. package/dist/esm/codexQuota.js +18 -5
  8. package/dist/esm/index.js +3 -3
  9. package/dist/esm/modelContext.js +32 -0
  10. package/dist/esm/modelInfo.js +186 -0
  11. package/dist/esm/multiProviderQuotaService.js +1 -1
  12. package/dist/esm/quota.js +356 -24
  13. package/dist/esm/quotaHistory.js +33 -8
  14. package/dist/esm/quotaPoller.js +5 -2
  15. package/dist/esm/quotaResolver.js +31 -6
  16. package/dist/esm/quotaSnapshots.js +65 -10
  17. package/dist/esm/quotaThresholds.js +7 -2
  18. package/dist/esm/schemas/index.js +2 -2
  19. package/dist/esm/schemas/quota.js +30 -0
  20. package/dist/esm/schemas/quotaHistory.js +10 -2
  21. package/dist/esm/schemas/stateFile.js +9 -0
  22. package/dist/esm/stateFile.js +18 -3
  23. package/dist/esm/statusline/claudeStatuslinePayload.js +16 -4
  24. package/dist/esm/statusline/formatter.js +57 -18
  25. package/dist/esm/statusline/index.js +2 -2
  26. package/dist/esm/zaiQuotaApi.js +4 -2
  27. package/dist/index.d.ts +6 -6
  28. package/dist/index.js +18 -5
  29. package/dist/modelContext.js +32 -0
  30. package/dist/modelInfo.js +186 -0
  31. package/dist/multiProviderQuotaService.js +1 -1
  32. package/dist/quota.d.ts +131 -1
  33. package/dist/quota.js +366 -24
  34. package/dist/quotaHistory.d.ts +10 -0
  35. package/dist/quotaHistory.js +34 -8
  36. package/dist/quotaPoller.d.ts +11 -3
  37. package/dist/quotaPoller.js +5 -2
  38. package/dist/quotaResolver.js +30 -5
  39. package/dist/quotaSnapshots.js +64 -9
  40. package/dist/quotaThresholds.js +7 -2
  41. package/dist/schemas/index.d.ts +3 -3
  42. package/dist/schemas/index.js +5 -2
  43. package/dist/schemas/quota.d.ts +258 -0
  44. package/dist/schemas/quota.js +31 -1
  45. package/dist/schemas/quotaHistory.d.ts +2 -0
  46. package/dist/schemas/quotaHistory.js +10 -2
  47. package/dist/schemas/stateFile.d.ts +34 -0
  48. package/dist/schemas/stateFile.js +10 -1
  49. package/dist/stateFile.d.ts +18 -0
  50. package/dist/stateFile.js +17 -2
  51. package/dist/statusline/claudeStatuslinePayload.d.ts +4 -3
  52. package/dist/statusline/claudeStatuslinePayload.js +16 -4
  53. package/dist/statusline/formatter.d.ts +5 -0
  54. package/dist/statusline/formatter.js +56 -17
  55. package/dist/statusline/index.d.ts +4 -4
  56. package/dist/statusline/index.js +5 -1
  57. package/dist/zaiQuotaApi.js +3 -1
  58. package/package.json +1 -1
@@ -37,6 +37,192 @@ export { MODEL_PROVIDER_IDS } from './providerIds';
37
37
  * prefix matching cannot bridge the two, so both are needed.
38
38
  */
39
39
  const PRICING_TABLE = {
40
+ // Current coding models verified 2026-10-30. Standard short-context rates;
41
+ // DeepSeek uses peak rates. Gemini promotions end 2026-11-30 (Argon) and
42
+ // 2026-12-31 (Flash). Haiku 5.5 costs more above 100K prompt tokens.
43
+ // Sources and estimation limits: docs/configuration/model-resolution.md.
44
+ // Explicit direct-provider keys support offline OpenCode model IDs without
45
+ // stripping arbitrary deployment prefixes or changing alias resolution.
46
+ 'gpt-6.1-sol': {
47
+ inputCostPerMillion: 2,
48
+ outputCostPerMillion: 10,
49
+ cacheWriteCostPerMillion: 2.5,
50
+ cacheReadCostPerMillion: 0.1,
51
+ },
52
+ 'openai/gpt-6.1-sol': {
53
+ inputCostPerMillion: 2,
54
+ outputCostPerMillion: 10,
55
+ cacheWriteCostPerMillion: 2.5,
56
+ cacheReadCostPerMillion: 0.1,
57
+ },
58
+ 'gpt-6.1-terra': {
59
+ inputCostPerMillion: 1,
60
+ outputCostPerMillion: 5,
61
+ cacheWriteCostPerMillion: 1.25,
62
+ cacheReadCostPerMillion: 0.05,
63
+ },
64
+ 'openai/gpt-6.1-terra': {
65
+ inputCostPerMillion: 1,
66
+ outputCostPerMillion: 5,
67
+ cacheWriteCostPerMillion: 1.25,
68
+ cacheReadCostPerMillion: 0.05,
69
+ },
70
+ 'claude-sonnet-5-5': {
71
+ inputCostPerMillion: 2,
72
+ outputCostPerMillion: 10,
73
+ cacheWriteCostPerMillion: 2.5,
74
+ cacheReadCostPerMillion: 0.1,
75
+ },
76
+ 'anthropic/claude-sonnet-5-5': {
77
+ inputCostPerMillion: 2,
78
+ outputCostPerMillion: 10,
79
+ cacheWriteCostPerMillion: 2.5,
80
+ cacheReadCostPerMillion: 0.1,
81
+ },
82
+ 'claude-sonnet-5.5': {
83
+ inputCostPerMillion: 2,
84
+ outputCostPerMillion: 10,
85
+ cacheWriteCostPerMillion: 2.5,
86
+ cacheReadCostPerMillion: 0.1,
87
+ },
88
+ 'anthropic/claude-sonnet-5.5': {
89
+ inputCostPerMillion: 2,
90
+ outputCostPerMillion: 10,
91
+ cacheWriteCostPerMillion: 2.5,
92
+ cacheReadCostPerMillion: 0.1,
93
+ },
94
+ 'claude-haiku-5-5': {
95
+ inputCostPerMillion: 0.1,
96
+ outputCostPerMillion: 0.5,
97
+ cacheWriteCostPerMillion: 0.125,
98
+ cacheReadCostPerMillion: 0.01,
99
+ },
100
+ 'anthropic/claude-haiku-5-5': {
101
+ inputCostPerMillion: 0.1,
102
+ outputCostPerMillion: 0.5,
103
+ cacheWriteCostPerMillion: 0.125,
104
+ cacheReadCostPerMillion: 0.01,
105
+ },
106
+ 'claude-haiku-5.5': {
107
+ inputCostPerMillion: 0.1,
108
+ outputCostPerMillion: 0.5,
109
+ cacheWriteCostPerMillion: 0.125,
110
+ cacheReadCostPerMillion: 0.01,
111
+ },
112
+ 'anthropic/claude-haiku-5.5': {
113
+ inputCostPerMillion: 0.1,
114
+ outputCostPerMillion: 0.5,
115
+ cacheWriteCostPerMillion: 0.125,
116
+ cacheReadCostPerMillion: 0.01,
117
+ },
118
+ 'gemini-4-argon': {
119
+ inputCostPerMillion: 2,
120
+ outputCostPerMillion: 10,
121
+ cacheWriteCostPerMillion: 0,
122
+ cacheReadCostPerMillion: 0.1,
123
+ },
124
+ 'gemini/gemini-4-argon': {
125
+ inputCostPerMillion: 2,
126
+ outputCostPerMillion: 10,
127
+ cacheWriteCostPerMillion: 0,
128
+ cacheReadCostPerMillion: 0.1,
129
+ },
130
+ 'gemini-3.8-flash': {
131
+ inputCostPerMillion: 0.75,
132
+ outputCostPerMillion: 3.75,
133
+ cacheWriteCostPerMillion: 0,
134
+ cacheReadCostPerMillion: 0.075,
135
+ },
136
+ 'gemini/gemini-3.8-flash': {
137
+ inputCostPerMillion: 0.75,
138
+ outputCostPerMillion: 3.75,
139
+ cacheWriteCostPerMillion: 0,
140
+ cacheReadCostPerMillion: 0.075,
141
+ },
142
+ 'deepseek-flash': {
143
+ inputCostPerMillion: 0.3,
144
+ outputCostPerMillion: 1.2,
145
+ cacheWriteCostPerMillion: 0,
146
+ cacheReadCostPerMillion: 0.006,
147
+ },
148
+ 'deepseek/deepseek-flash': {
149
+ inputCostPerMillion: 0.3,
150
+ outputCostPerMillion: 1.2,
151
+ cacheWriteCostPerMillion: 0,
152
+ cacheReadCostPerMillion: 0.006,
153
+ },
154
+ 'deepseek-v4-flash': {
155
+ inputCostPerMillion: 0.3,
156
+ outputCostPerMillion: 1.2,
157
+ cacheWriteCostPerMillion: 0,
158
+ cacheReadCostPerMillion: 0.006,
159
+ },
160
+ 'deepseek/deepseek-v4-flash': {
161
+ inputCostPerMillion: 0.3,
162
+ outputCostPerMillion: 1.2,
163
+ cacheWriteCostPerMillion: 0,
164
+ cacheReadCostPerMillion: 0.006,
165
+ },
166
+ 'deepseek-v4-flash-vision-exp': {
167
+ inputCostPerMillion: 0.3,
168
+ outputCostPerMillion: 1.2,
169
+ cacheWriteCostPerMillion: 0,
170
+ cacheReadCostPerMillion: 0.006,
171
+ },
172
+ 'deepseek/deepseek-v4-flash-vision-exp': {
173
+ inputCostPerMillion: 0.3,
174
+ outputCostPerMillion: 1.2,
175
+ cacheWriteCostPerMillion: 0,
176
+ cacheReadCostPerMillion: 0.006,
177
+ },
178
+ 'deepseek-v4-pro': {
179
+ inputCostPerMillion: 1.32,
180
+ outputCostPerMillion: 3.96,
181
+ cacheWriteCostPerMillion: 0,
182
+ cacheReadCostPerMillion: 0.044,
183
+ },
184
+ 'deepseek/deepseek-v4-pro': {
185
+ inputCostPerMillion: 1.32,
186
+ outputCostPerMillion: 3.96,
187
+ cacheWriteCostPerMillion: 0,
188
+ cacheReadCostPerMillion: 0.044,
189
+ },
190
+ 'glm-5.3': {
191
+ inputCostPerMillion: 1.4,
192
+ outputCostPerMillion: 4.4,
193
+ cacheWriteCostPerMillion: 0,
194
+ cacheReadCostPerMillion: 0.26,
195
+ },
196
+ 'zai/glm-5.3': {
197
+ inputCostPerMillion: 1.4,
198
+ outputCostPerMillion: 4.4,
199
+ cacheWriteCostPerMillion: 0,
200
+ cacheReadCostPerMillion: 0.26,
201
+ },
202
+ 'glm-5.3-flash': {
203
+ inputCostPerMillion: 0.15,
204
+ outputCostPerMillion: 0.5,
205
+ cacheWriteCostPerMillion: 0,
206
+ cacheReadCostPerMillion: 0.03,
207
+ },
208
+ 'zai/glm-5.3-flash': {
209
+ inputCostPerMillion: 0.15,
210
+ outputCostPerMillion: 0.5,
211
+ cacheWriteCostPerMillion: 0,
212
+ cacheReadCostPerMillion: 0.03,
213
+ },
214
+ 'glm-5.3-flashx': {
215
+ inputCostPerMillion: 0.37,
216
+ outputCostPerMillion: 1.25,
217
+ cacheWriteCostPerMillion: 0,
218
+ cacheReadCostPerMillion: 0.075,
219
+ },
220
+ 'zai/glm-5.3-flashx': {
221
+ inputCostPerMillion: 0.37,
222
+ outputCostPerMillion: 1.25,
223
+ cacheWriteCostPerMillion: 0,
224
+ cacheReadCostPerMillion: 0.075,
225
+ },
40
226
  // ── Anthropic: Claude ──
41
227
  // Fable 5.1 cache reads cost 0.025x input; Fable 5 retains its 0.1x rate.
42
228
  'claude-fable-5-1': {
@@ -290,7 +290,7 @@ export class MultiProviderQuotaService {
290
290
  await this.refreshPeakHours();
291
291
  const creds = await this.readClaudeCredentials();
292
292
  if (!creds) {
293
- this.handleUnavailableState(makeUnavailableClaudeState('Renew your Claude Max OAuth to view quota', 'auth'));
293
+ this.handleUnavailableState(makeUnavailableClaudeState('Renew your Claude login to view quota', 'auth'));
294
294
  return;
295
295
  }
296
296
  const state = await this.fetchClaudeQuota(creds.accessToken);
package/dist/esm/quota.js CHANGED
@@ -18,6 +18,126 @@ export function classifyQuotaFreshness(ageMs) {
18
18
  return 'aging';
19
19
  return 'stale';
20
20
  }
21
+ const HOUR_MS = 3_600_000;
22
+ const DAY_MS = 86_400_000;
23
+ /** Whether the provider reports this window for the account at all. */
24
+ export function quotaWindowApplies(window) {
25
+ return Boolean(window) && window?.absent !== true;
26
+ }
27
+ /** Whether the window applies and carries a reading worth showing (a reset time or any usage). */
28
+ export function quotaWindowHasData(window) {
29
+ if (!window || window.absent === true)
30
+ return false;
31
+ return Boolean(window.resetsAt) || window.utilization > 0;
32
+ }
33
+ function usableWindowMs(windowMs) {
34
+ return typeof windowMs === 'number' && Number.isFinite(windowMs) && windowMs > 0
35
+ ? windowMs
36
+ : undefined;
37
+ }
38
+ /**
39
+ * True when every window of a sample that has a known reset time has already
40
+ * reset by `nowMs`. Such a sample says nothing about current usage — showing
41
+ * it as live would print old percentages with "resets now". Samples without
42
+ * any reset time are never considered expired.
43
+ */
44
+ export function quotaSampleExpired(quota, nowMs = Date.now()) {
45
+ const resets = [quota.fiveHour, quota.sevenDay]
46
+ .filter((window) => !window.absent && window.resetsAt)
47
+ .map((window) => Date.parse(window.resetsAt))
48
+ .filter((ms) => Number.isFinite(ms));
49
+ return resets.length > 0 && resets.every((ms) => ms <= nowMs);
50
+ }
51
+ /**
52
+ * Human label for a window length: `5-Hour`, `Daily`, `Weekly`, `30-Day`, …
53
+ * Returns `fallback` when the length is unknown or not a whole number of hours.
54
+ */
55
+ export function quotaWindowLabel(windowMs, fallback) {
56
+ const ms = usableWindowMs(windowMs);
57
+ if (ms === undefined)
58
+ return fallback;
59
+ if (ms === DAY_MS)
60
+ return 'Daily';
61
+ if (ms === 7 * DAY_MS)
62
+ return 'Weekly';
63
+ if (ms % DAY_MS === 0)
64
+ return `${ms / DAY_MS}-Day`;
65
+ if (ms % HOUR_MS === 0)
66
+ return `${ms / HOUR_MS}-Hour`;
67
+ return fallback;
68
+ }
69
+ /** Compact label for a window length: `5h`, `1d`, `7d`. Returns `fallback` when unknown. */
70
+ export function quotaWindowShortLabel(windowMs, fallback) {
71
+ const ms = usableWindowMs(windowMs);
72
+ if (ms === undefined)
73
+ return fallback;
74
+ if (ms % DAY_MS === 0)
75
+ return `${ms / DAY_MS}d`;
76
+ if (ms % HOUR_MS === 0)
77
+ return `${ms / HOUR_MS}h`;
78
+ if (ms % 60_000 === 0)
79
+ return `${ms / 60_000}m`;
80
+ return fallback;
81
+ }
82
+ /**
83
+ * The reported meters that `fiveHour` and `sevenDay` do not already show —
84
+ * per-model or per-surface weekly caps and any future meter kind.
85
+ */
86
+ export function extraQuotaRows(quota) {
87
+ return (quota.limits ?? []).filter((row) => row.kind !== 'session' && row.kind !== 'weekly_all');
88
+ }
89
+ /**
90
+ * Display name for a Claude subscription. The 20× tier was renamed Ultra and
91
+ * the 5× tier Max on 2026-10-30; credentials keep the old tier ids.
92
+ */
93
+ export function claudePlanLabel(plan) {
94
+ const tier = plan?.rateLimitTier?.trim().toLowerCase() ?? '';
95
+ if (tier.includes('ultra') || tier.includes('max_20x'))
96
+ return 'Ultra';
97
+ if (tier.includes('max_5x'))
98
+ return 'Max';
99
+ const subscription = plan?.subscriptionType?.trim().toLowerCase();
100
+ if (!subscription)
101
+ return undefined;
102
+ return subscription.charAt(0).toUpperCase() + subscription.slice(1);
103
+ }
104
+ function formatMinorUnits(amount, currency, decimals) {
105
+ const value = amount / 10 ** decimals;
106
+ if (currency) {
107
+ try {
108
+ return new Intl.NumberFormat('en-US', {
109
+ style: 'currency',
110
+ currency,
111
+ minimumFractionDigits: decimals,
112
+ maximumFractionDigits: decimals,
113
+ }).format(value);
114
+ }
115
+ catch {
116
+ return `${value.toFixed(decimals)} ${currency}`;
117
+ }
118
+ }
119
+ return value.toFixed(decimals);
120
+ }
121
+ /**
122
+ * One-line extra-usage summary, e.g. `$12.40 of $200.00 used` or
123
+ * `$0.00 of $200.00 used · off (out of credits)`.
124
+ */
125
+ export function formatExtraUsage(extra) {
126
+ const decimals = typeof extra.decimalPlaces === 'number' &&
127
+ Number.isInteger(extra.decimalPlaces) &&
128
+ extra.decimalPlaces >= 0 &&
129
+ extra.decimalPlaces <= 6
130
+ ? extra.decimalPlaces
131
+ : 2;
132
+ const used = formatMinorUnits(extra.usedCredits ?? 0, extra.currency, decimals);
133
+ const amount = extra.monthlyLimit !== null
134
+ ? `${used} of ${formatMinorUnits(extra.monthlyLimit, extra.currency, decimals)} used`
135
+ : `${used} used`;
136
+ if (extra.enabled)
137
+ return amount;
138
+ const reason = extra.disabledReason ? ` (${extra.disabledReason.replace(/_/g, ' ')})` : '';
139
+ return `${amount} · off${reason}`;
140
+ }
21
141
  /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
22
142
  export function formatQuotaAge(ageMs) {
23
143
  if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
@@ -56,21 +176,21 @@ export function withQuotaProjections(state, options = {}) {
56
176
  return state;
57
177
  const next = { ...state };
58
178
  const capturedAt = options.capturedAt ?? state.capturedAt;
59
- if (next.projectedFiveHour == null) {
179
+ if (next.projectedFiveHour == null && !next.fiveHour.absent) {
60
180
  const projected = projectQuotaWindow({
61
181
  utilization: next.fiveHour.utilization,
62
182
  resetsAt: next.fiveHour.resetsAt,
63
- windowMs: options.fiveHourWindowMs ?? FIVE_HOUR_WINDOW_MS,
183
+ windowMs: options.fiveHourWindowMs ?? usableWindowMs(next.fiveHour.windowMs) ?? FIVE_HOUR_WINDOW_MS,
64
184
  capturedAt,
65
185
  });
66
186
  if (projected !== undefined)
67
187
  next.projectedFiveHour = projected;
68
188
  }
69
- if (next.projectedSevenDay == null) {
189
+ if (next.projectedSevenDay == null && !next.sevenDay.absent) {
70
190
  const projected = projectQuotaWindow({
71
191
  utilization: next.sevenDay.utilization,
72
192
  resetsAt: next.sevenDay.resetsAt,
73
- windowMs: options.sevenDayWindowMs ?? SEVEN_DAY_WINDOW_MS,
193
+ windowMs: options.sevenDayWindowMs ?? usableWindowMs(next.sevenDay.windowMs) ?? SEVEN_DAY_WINDOW_MS,
74
194
  capturedAt,
75
195
  });
76
196
  if (projected !== undefined)
@@ -78,6 +198,235 @@ export function withQuotaProjections(state, options = {}) {
78
198
  }
79
199
  return next;
80
200
  }
201
+ // ── Claude usage endpoint payload ──
202
+ /** Hard cap on parsed meter rows, matching Claude Code's own bound. */
203
+ const MAX_LIMIT_ROWS = 64;
204
+ const MAX_LABEL_LENGTH = 128;
205
+ function isRecord(value) {
206
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
207
+ }
208
+ function finiteNumber(value) {
209
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
210
+ }
211
+ function nullableFiniteNumber(value) {
212
+ return finiteNumber(value) ?? null;
213
+ }
214
+ function shortString(value) {
215
+ if (typeof value !== 'string')
216
+ return undefined;
217
+ const trimmed = value.trim();
218
+ return trimmed && trimmed.length <= MAX_LABEL_LENGTH ? trimmed : undefined;
219
+ }
220
+ function displayName(value) {
221
+ return isRecord(value) ? shortString(value.display_name) : undefined;
222
+ }
223
+ function usageWindow(value) {
224
+ if (!isRecord(value))
225
+ return null;
226
+ return {
227
+ utilization: finiteNumber(value.utilization) ?? 0,
228
+ resetsAt: typeof value.resets_at === 'string' ? value.resets_at : '',
229
+ };
230
+ }
231
+ function absentWindow() {
232
+ return { utilization: 0, resetsAt: '', absent: true };
233
+ }
234
+ function windowMsForMeter(kind, group) {
235
+ if (kind === 'session' || group === 'session')
236
+ return FIVE_HOUR_WINDOW_MS;
237
+ if (group === 'weekly' || kind.startsWith('weekly'))
238
+ return SEVEN_DAY_WINDOW_MS;
239
+ if (group === 'daily')
240
+ return DAY_MS;
241
+ return undefined;
242
+ }
243
+ function titleCase(value) {
244
+ return value
245
+ .split(/[_\s-]+/)
246
+ .filter(Boolean)
247
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
248
+ .join(' ');
249
+ }
250
+ function meterLabel(kind, group, scope) {
251
+ let base;
252
+ if (kind === 'session' || group === 'session')
253
+ base = '5-Hour';
254
+ else if (group === 'weekly' || kind.startsWith('weekly'))
255
+ base = '7-Day';
256
+ else if (group === 'daily')
257
+ base = 'Daily';
258
+ else
259
+ base = titleCase(group || kind) || kind;
260
+ return scope ? `${base} (${scope})` : base;
261
+ }
262
+ function weeklySharesByIndex(value) {
263
+ const shares = new Map();
264
+ if (!Array.isArray(value))
265
+ return shares;
266
+ for (const entry of value.slice(0, MAX_LIMIT_ROWS)) {
267
+ if (!isRecord(entry))
268
+ continue;
269
+ const index = finiteNumber(entry.limit_index);
270
+ if (index === undefined || !Number.isInteger(index) || index < 0)
271
+ continue;
272
+ shares.set(index, {
273
+ allowance: finiteNumber(entry.allowance_percent_of_weekly),
274
+ used: finiteNumber(entry.used_percent_of_weekly),
275
+ });
276
+ }
277
+ return shares;
278
+ }
279
+ function buildMeterRow(input, capturedAt) {
280
+ const windowMs = windowMsForMeter(input.kind, input.group);
281
+ const projected = windowMs !== undefined
282
+ ? projectQuotaWindow({
283
+ utilization: input.utilization,
284
+ resetsAt: input.resetsAt,
285
+ windowMs,
286
+ capturedAt,
287
+ })
288
+ : undefined;
289
+ return {
290
+ kind: input.kind,
291
+ group: input.group,
292
+ label: meterLabel(input.kind, input.group, input.scopeModel ?? input.scopeSurface),
293
+ utilization: input.utilization,
294
+ resetsAt: input.resetsAt,
295
+ ...(windowMs !== undefined ? { windowMs } : {}),
296
+ ...(projected !== undefined ? { projected } : {}),
297
+ ...(input.scopeModel ? { scopeModel: input.scopeModel } : {}),
298
+ ...(input.scopeSurface ? { scopeSurface: input.scopeSurface } : {}),
299
+ ...(input.severity ? { severity: input.severity } : {}),
300
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
301
+ ...(input.share?.allowance !== undefined
302
+ ? { allowancePercentOfWeekly: input.share.allowance }
303
+ : {}),
304
+ ...(input.share?.used !== undefined ? { usedPercentOfWeekly: input.share.used } : {}),
305
+ };
306
+ }
307
+ function parseLimitRows(value, sharesValue, capturedAt) {
308
+ if (!Array.isArray(value))
309
+ return undefined;
310
+ const shares = weeklySharesByIndex(sharesValue);
311
+ const rows = [];
312
+ value.slice(0, MAX_LIMIT_ROWS).forEach((raw, index) => {
313
+ if (!isRecord(raw))
314
+ return;
315
+ const kind = shortString(raw.kind);
316
+ const percent = finiteNumber(raw.percent);
317
+ if (!kind || percent === undefined)
318
+ return;
319
+ const scope = isRecord(raw.scope) ? raw.scope : undefined;
320
+ rows.push(buildMeterRow({
321
+ kind,
322
+ group: shortString(raw.group) ?? '',
323
+ utilization: Math.max(0, percent),
324
+ resetsAt: typeof raw.resets_at === 'string' ? raw.resets_at : '',
325
+ scopeModel: displayName(scope?.model),
326
+ scopeSurface: displayName(scope?.surface),
327
+ severity: shortString(raw.severity),
328
+ isActive: typeof raw.is_active === 'boolean' ? raw.is_active : undefined,
329
+ share: shares.get(index),
330
+ }, capturedAt));
331
+ });
332
+ return rows;
333
+ }
334
+ /** Pre-`limits[]` per-model weekly fields, read only when the server sends no `limits[]`. */
335
+ const LEGACY_SCOPED_WEEKLY_FIELDS = [
336
+ ['seven_day_opus', 'Opus'],
337
+ ['seven_day_sonnet', 'Sonnet'],
338
+ ['seven_day_overage_included', 'Fable'],
339
+ ];
340
+ function legacyScopedRows(data, capturedAt) {
341
+ const rows = [];
342
+ for (const [field, model] of LEGACY_SCOPED_WEEKLY_FIELDS) {
343
+ const window = usageWindow(data[field]);
344
+ if (!window)
345
+ continue;
346
+ rows.push(buildMeterRow({
347
+ kind: 'weekly_scoped',
348
+ group: 'weekly',
349
+ utilization: window.utilization,
350
+ resetsAt: window.resetsAt,
351
+ scopeModel: model,
352
+ }, capturedAt));
353
+ }
354
+ return rows;
355
+ }
356
+ function parseExtraUsage(value) {
357
+ if (!isRecord(value) || typeof value.is_enabled !== 'boolean')
358
+ return undefined;
359
+ const decimalPlaces = finiteNumber(value.decimal_places);
360
+ return {
361
+ enabled: value.is_enabled,
362
+ monthlyLimit: nullableFiniteNumber(value.monthly_limit),
363
+ usedCredits: nullableFiniteNumber(value.used_credits),
364
+ utilization: nullableFiniteNumber(value.utilization),
365
+ ...(shortString(value.currency) ? { currency: shortString(value.currency) } : {}),
366
+ ...(decimalPlaces !== undefined ? { decimalPlaces } : {}),
367
+ ...(shortString(value.disabled_reason)
368
+ ? { disabledReason: shortString(value.disabled_reason) }
369
+ : {}),
370
+ };
371
+ }
372
+ function windowFromMeter(row) {
373
+ return row ? { utilization: row.utilization, resetsAt: row.resetsAt } : null;
374
+ }
375
+ /**
376
+ * Normalize a Claude OAuth usage-endpoint body into a `QuotaState`.
377
+ *
378
+ * - `five_hour`/`seven_day` that are null or missing become `absent` windows
379
+ * (Ultra plans have no five-hour session limit), unless `limits[]` carries
380
+ * the matching `session`/`weekly_all` meter.
381
+ * - `limits[]` is kept verbatim as `limits` (with derived labels, window
382
+ * lengths, and projections); older servers' `seven_day_opus` /
383
+ * `seven_day_sonnet` / `seven_day_overage_included` fill it instead.
384
+ * - `extra_usage` becomes `extraUsage`.
385
+ *
386
+ * Bodies in the original two-window shape produce exactly the state they did
387
+ * before these fields existed.
388
+ */
389
+ export function quotaStateFromClaudeUsagePayload(payload, options = {}) {
390
+ const data = isRecord(payload) ? payload : {};
391
+ const capturedAt = options.capturedAt;
392
+ const parsedLimits = parseLimitRows(data.limits, data.weekly_scoped_shares, capturedAt);
393
+ const legacyRows = parsedLimits === undefined ? legacyScopedRows(data, capturedAt) : [];
394
+ const limits = parsedLimits ?? (legacyRows.length > 0 ? legacyRows : undefined);
395
+ const fiveHour = usageWindow(data.five_hour) ??
396
+ windowFromMeter(parsedLimits?.find((row) => row.kind === 'session')) ??
397
+ absentWindow();
398
+ const sevenDay = usageWindow(data.seven_day) ??
399
+ windowFromMeter(parsedLimits?.find((row) => row.kind === 'weekly_all')) ??
400
+ absentWindow();
401
+ const extraUsage = parseExtraUsage(data.extra_usage);
402
+ const projectedFiveHour = fiveHour.absent
403
+ ? undefined
404
+ : projectQuotaWindow({ ...fiveHour, windowMs: FIVE_HOUR_WINDOW_MS, capturedAt });
405
+ const projectedSevenDay = sevenDay.absent
406
+ ? undefined
407
+ : projectQuotaWindow({ ...sevenDay, windowMs: SEVEN_DAY_WINDOW_MS, capturedAt });
408
+ return {
409
+ fiveHour,
410
+ sevenDay,
411
+ available: true,
412
+ projectedFiveHour,
413
+ projectedSevenDay,
414
+ ...(limits ? { limits } : {}),
415
+ ...(extraUsage ? { extraUsage } : {}),
416
+ };
417
+ }
418
+ /** Stamp `planType` / `planLabel` from Claude credential plan facts (no-op without them). */
419
+ export function withClaudePlan(state, plan) {
420
+ const planLabel = claudePlanLabel(plan);
421
+ const planType = plan?.subscriptionType?.trim() || undefined;
422
+ if (!planLabel && !planType)
423
+ return state;
424
+ return {
425
+ ...state,
426
+ ...(planType ? { planType } : {}),
427
+ ...(planLabel ? { planLabel } : {}),
428
+ };
429
+ }
81
430
  /**
82
431
  * Helper to build an unavailable QuotaState.
83
432
  */
@@ -110,6 +459,7 @@ function parseRetryAfterMs(retryAfter) {
110
459
  *
111
460
  * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
112
461
  * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
462
+ * @param options.plan - Plan facts from the same credentials; stamps `planType` and `planLabel`
113
463
  * @returns QuotaState with utilization data and elapsed-time projections
114
464
  */
115
465
  export async function fetchQuota(accessToken, options = {}) {
@@ -152,26 +502,8 @@ export async function fetchQuota(accessToken, options = {}) {
152
502
  httpStatus: res.status,
153
503
  });
154
504
  }
155
- const data = await res.json();
156
- const fiveUtil = data.five_hour?.utilization ?? 0;
157
- const sevenUtil = data.seven_day?.utilization ?? 0;
158
- const fiveResetsAt = data.five_hour?.resets_at ?? '';
159
- const sevenResetsAt = data.seven_day?.resets_at ?? '';
160
- return {
161
- fiveHour: { utilization: fiveUtil, resetsAt: fiveResetsAt },
162
- sevenDay: { utilization: sevenUtil, resetsAt: sevenResetsAt },
163
- available: true,
164
- projectedFiveHour: projectQuotaWindow({
165
- utilization: fiveUtil,
166
- resetsAt: fiveResetsAt,
167
- windowMs: FIVE_HOUR_WINDOW_MS,
168
- }),
169
- projectedSevenDay: projectQuotaWindow({
170
- utilization: sevenUtil,
171
- resetsAt: sevenResetsAt,
172
- windowMs: SEVEN_DAY_WINDOW_MS,
173
- }),
174
- };
505
+ const state = quotaStateFromClaudeUsagePayload(await res.json());
506
+ return withClaudePlan(state, options.plan);
175
507
  }
176
508
  catch {
177
509
  return unavailableState('Network error', { failureKind: 'network' });
@@ -159,11 +159,26 @@ function pruneFileSync(filePath, retentionDays) {
159
159
  }
160
160
  return { kept: keptLines.length, pruned };
161
161
  }
162
+ function historyWindowToQuota(window) {
163
+ return {
164
+ utilization: window.utilization,
165
+ resetsAt: window.resetsAt,
166
+ ...(window.absent ? { absent: true } : {}),
167
+ };
168
+ }
169
+ /**
170
+ * Project a quota window onto the history-sample shape, keeping the `absent`
171
+ * marker so summaries and the derived snapshot do not mistake "no such
172
+ * window" for "0% used".
173
+ */
174
+ export function quotaHistoryWindow(window) {
175
+ return historyWindowToQuota(window);
176
+ }
162
177
  function sampleToQuotaState(sample) {
163
178
  const providerId = runtimeProviderToSnapshotProvider(sample.runtimeProvider);
164
179
  return {
165
- fiveHour: { utilization: sample.fiveHour.utilization, resetsAt: sample.fiveHour.resetsAt },
166
- sevenDay: { utilization: sample.sevenDay.utilization, resetsAt: sample.sevenDay.resetsAt },
180
+ fiveHour: historyWindowToQuota(sample.fiveHour),
181
+ sevenDay: historyWindowToQuota(sample.sevenDay),
167
182
  available: sample.available,
168
183
  error: sample.error,
169
184
  providerId,
@@ -329,12 +344,22 @@ export async function readQuotaHistoryDailyBuckets(options) {
329
344
  let maxSeven = 0;
330
345
  let sumFive = 0;
331
346
  let sumSeven = 0;
347
+ // Averages count only samples where the window exists, so a plan without
348
+ // a five-hour limit does not drag the five-hour average toward zero.
349
+ let fiveCount = 0;
350
+ let sevenCount = 0;
332
351
  let anyUnavailable = false;
333
352
  for (const s of daySamples) {
334
- maxFive = Math.max(maxFive, s.fiveHour.utilization);
335
- maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
336
- sumFive += s.fiveHour.utilization;
337
- sumSeven += s.sevenDay.utilization;
353
+ if (!s.fiveHour.absent) {
354
+ maxFive = Math.max(maxFive, s.fiveHour.utilization);
355
+ sumFive += s.fiveHour.utilization;
356
+ fiveCount += 1;
357
+ }
358
+ if (!s.sevenDay.absent) {
359
+ maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
360
+ sumSeven += s.sevenDay.utilization;
361
+ sevenCount += 1;
362
+ }
338
363
  if (!s.available)
339
364
  anyUnavailable = true;
340
365
  }
@@ -344,8 +369,8 @@ export async function readQuotaHistoryDailyBuckets(options) {
344
369
  samples: n,
345
370
  maxUtilizationFiveHour: maxFive,
346
371
  maxUtilizationSevenDay: maxSeven,
347
- avgUtilizationFiveHour: Math.round((sumFive / n) * 100) / 100,
348
- avgUtilizationSevenDay: Math.round((sumSeven / n) * 100) / 100,
372
+ avgUtilizationFiveHour: fiveCount ? Math.round((sumFive / fiveCount) * 100) / 100 : 0,
373
+ avgUtilizationSevenDay: sevenCount ? Math.round((sumSeven / sevenCount) * 100) / 100 : 0,
349
374
  anyUnavailable,
350
375
  });
351
376
  }