@oar-sh/oar 0.9.16 → 0.9.18

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 (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,445 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Claude API spend for the Check Usage card and the monthly cap (plan
5
+ * decisions 10 and 12).
6
+ *
7
+ * The figures are what claude-api-usage-service.mjs booked into
8
+ * claude_api_usage_totals (migration 0034): per local day of the relay and
9
+ * model, Claude Code's own cost estimate (`modelUsage[*].costUSD`), and the
10
+ * tokens of turns it could not price (`unknown_cost_tokens`). OAR keeps no
11
+ * price table of its own.
12
+ *
13
+ * - **Today and this month** are the relay's local days, as the booking
14
+ * writes them: the month is the local days `YYYY-MM-01` to `YYYY-MM-31` of
15
+ * today's local month, read with the booking's own `localDay`, never from
16
+ * UTC dates. A 23 or 25 hour day (DST) is one day like any other.
17
+ * - **Unpriced** tokens (a model Claude Code has no price for, a resumed
18
+ * turn booked without its mark) are not $0: the card says the estimate is
19
+ * incomplete, and the cap counts only what is priced.
20
+ * - **No turn count.** `turn_count` counts the posts that booked something
21
+ * per model, so a turn on two models counts twice; the card shows spend and
22
+ * tokens only.
23
+ * - **The cap** is reached when this month's priced estimate, in cents, is
24
+ * at or above `claude_api_monthly_cap_usd`. The runtime asks it through
25
+ * getClaudeApiCapState: the claim of a Claude API row, the billing switch
26
+ * to the key, an agent's send to a chat on the key and a new Claude API
27
+ * chat are refused while it is reached. A running turn finishes.
28
+ */
29
+
30
+ import {
31
+ SOURCE_WORKER,
32
+ STATUS_OK,
33
+ STATUS_UNAVAILABLE,
34
+ buildDetailSection,
35
+ buildMeter,
36
+ buildProviderCard,
37
+ roundCurrency,
38
+ toFiniteNumber,
39
+ toIsoTimestamp,
40
+ } from './plan-usage-contract.mjs';
41
+ import { CLAUDE_API_PROVIDER_LABEL, CLAUDE_API_PROVIDER_TYPE } from './claude-api-settings-service.mjs';
42
+ import { claudeAttributionModelLabel } from '../../shared/claude-attribution.mjs';
43
+
44
+ export const CLAUDE_API_CONSOLE_BILLING_URL = 'https://platform.claude.com/settings/billing';
45
+ export const CLAUDE_API_CONSOLE_LIMITS_URL = 'https://platform.claude.com/settings/limits';
46
+ export const CLAUDE_API_CAP_REACHED_CODE = 'CLAUDE_API_CAP_REACHED';
47
+
48
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
49
+ const HOUR_MS = 3_600_000;
50
+
51
+ function count(value) {
52
+ const number = toFiniteNumber(value);
53
+ return number !== null && number > 0 ? Math.round(number) : 0;
54
+ }
55
+
56
+ function money(value) {
57
+ const number = toFiniteNumber(value);
58
+ return number !== null && number > 0 ? number : 0;
59
+ }
60
+
61
+ /** "$1.23"; an amount under one cent keeps four decimals ("$0.0042"). */
62
+ export function formatClaudeApiUsd(value) {
63
+ const number = money(value);
64
+ const digits = number !== 0 && number < 0.01 ? 4 : 2;
65
+ return `$${number.toFixed(digits)}`;
66
+ }
67
+
68
+ function formatTokens(value) {
69
+ const number = count(value);
70
+ if (number < 1000) return String(number);
71
+ if (number < 1_000_000) return `${(number / 1000).toFixed(1)}k`;
72
+ return `${(number / 1_000_000).toFixed(1)}M`;
73
+ }
74
+
75
+ /** "Oct 1" for a `YYYY-MM-DD` day (a calendar date, no zone). */
76
+ function dayLabel(day) {
77
+ const date = new Date(`${day}T00:00:00.000Z`);
78
+ if (Number.isNaN(date.getTime())) return day;
79
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
80
+ }
81
+
82
+ /**
83
+ * The local month around `today` (`YYYY-MM-DD`, the relay's local date):
84
+ * `{ today, month, monthStart, monthEnd, nextMonthStart }`. `monthEnd` is
85
+ * day 31, which as a text bound holds every month's last day.
86
+ */
87
+ export function claudeApiMonthBounds(today) {
88
+ const day = String(today || '');
89
+ if (!DAY_PATTERN.test(day)) return null;
90
+ const year = Number(day.slice(0, 4));
91
+ const month = Number(day.slice(5, 7));
92
+ const next = month === 12 ? { year: year + 1, month: 1 } : { year, month: month + 1 };
93
+ const ym = day.slice(0, 7);
94
+ return {
95
+ today: day,
96
+ month: ym,
97
+ monthStart: `${ym}-01`,
98
+ monthEnd: `${ym}-31`,
99
+ nextMonthStart: `${String(next.year).padStart(4, '0')}-${String(next.month).padStart(2, '0')}-01`,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * The first instant whose local day (by `localDay`, the booking's) is `day`
105
+ * or later, as an ISO time: when a local month starts. Searched between 15
106
+ * hours before and 13 hours after that day's UTC midnight, which brackets
107
+ * every zone's midnight, so the answer follows the zone and its DST rules
108
+ * without knowing them.
109
+ */
110
+ export function claudeApiLocalDayStart(localDay, day) {
111
+ if (typeof localDay !== 'function' || !DAY_PATTERN.test(String(day || ''))) return null;
112
+ const midnight = Date.parse(`${day}T00:00:00.000Z`);
113
+ let lo = midnight - 15 * HOUR_MS; // local day before `day` in every zone
114
+ let hi = midnight + 13 * HOUR_MS; // `day` or later in every zone
115
+ if (localDay(new Date(lo).toISOString()) >= day || localDay(new Date(hi).toISOString()) < day) return null;
116
+ // To the millisecond: about 27 steps.
117
+ while (hi - lo > 1) {
118
+ const mid = lo + Math.floor((hi - lo) / 2);
119
+ if (localDay(new Date(mid).toISOString()) >= day) hi = mid;
120
+ else lo = mid;
121
+ }
122
+ return new Date(hi).toISOString();
123
+ }
124
+
125
+ function sumModels(rows) {
126
+ const byModel = new Map();
127
+ for (const row of rows) {
128
+ const model = String(row?.model || '').trim().toLowerCase();
129
+ if (!model) continue;
130
+ const current = byModel.get(model) || {
131
+ model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, unknownCostTokens: 0, lastSeenAt: null,
132
+ };
133
+ current.hitTokens += count(row.hit_tokens);
134
+ current.missTokens += count(row.miss_tokens);
135
+ current.outputTokens += count(row.output_tokens);
136
+ current.costUsd += money(row.cost_usd);
137
+ current.unknownCostTokens += count(row.unknown_cost_tokens);
138
+ const seen = toIsoTimestamp(row.last_seen_at);
139
+ if (seen && (!current.lastSeenAt || seen > current.lastSeenAt)) current.lastSeenAt = seen;
140
+ byModel.set(model, current);
141
+ }
142
+ return [...byModel.values()].sort((a, b) => (b.costUsd - a.costUsd) || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
143
+ }
144
+
145
+ function totalOf(models) {
146
+ return {
147
+ costUsd: models.reduce((sum, entry) => sum + entry.costUsd, 0),
148
+ unknownCostTokens: models.reduce((sum, entry) => sum + entry.unknownCostTokens, 0),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * This month's and today's booked spend, from the booking service
154
+ * (`readDaysBetween`, `localDay`). Returns `{ bounds, today, month }`, each
155
+ * of `today`/`month` being `{ models, costUsd, unknownCostTokens }`, plus
156
+ * `lastSeenAt`.
157
+ */
158
+ export function readClaudeApiSpend({ usageService, now = Date.now() } = {}) {
159
+ if (typeof usageService?.localDay !== 'function' || typeof usageService?.readDaysBetween !== 'function') {
160
+ throw new Error('readClaudeApiSpend requires the Claude API usage service');
161
+ }
162
+ const bounds = claudeApiMonthBounds(usageService.localDay(new Date(now).toISOString()));
163
+ if (!bounds) throw new Error('The relay\'s local date could not be read');
164
+ const rows = usageService.readDaysBetween(bounds.monthStart, bounds.monthEnd)
165
+ .filter((row) => DAY_PATTERN.test(String(row?.day || '')) && row.day >= bounds.monthStart && row.day <= bounds.monthEnd);
166
+ const monthModels = sumModels(rows);
167
+ const todayModels = sumModels(rows.filter((row) => row.day === bounds.today));
168
+ const lastSeenAt = monthModels.map((entry) => entry.lastSeenAt).filter(Boolean).sort().pop() || null;
169
+ return {
170
+ bounds,
171
+ today: { models: todayModels, ...totalOf(todayModels) },
172
+ month: { models: monthModels, ...totalOf(monthModels) },
173
+ lastSeenAt,
174
+ };
175
+ }
176
+
177
+ /** The cap from the settings: a positive number of US dollars, or null. */
178
+ function capOf(value) {
179
+ const number = toFiniteNumber(value);
180
+ return number !== null && number > 0 ? number : null;
181
+ }
182
+
183
+ /**
184
+ * The cap against this month's spend: `{ reached, capUsd, spentUsd,
185
+ * incomplete, error? }`. `error` (only when reached) is the refusal's first
186
+ * sentence; the callers add what did not happen and the way out.
187
+ */
188
+ export function claudeApiCapStateFrom({ capUsd = null, spend = null } = {}) {
189
+ const cap = capOf(capUsd);
190
+ const spentUsd = roundCurrency(money(spend?.month?.costUsd)) || 0;
191
+ const incomplete = count(spend?.month?.unknownCostTokens) > 0;
192
+ if (cap === null) return { reached: false, capUsd: null, spentUsd, incomplete };
193
+ const reached = spentUsd >= cap;
194
+ return {
195
+ reached,
196
+ capUsd: cap,
197
+ spentUsd,
198
+ incomplete,
199
+ // One stretch at the cap: this local month and this cap. A note said once
200
+ // per chat per episode (messages-routes.mjs, continuations on the key).
201
+ episode: `${String(spend?.bounds?.month || '')}:${cap}`,
202
+ // `reason` without the place, for a message that names it in its remedy.
203
+ ...(reached
204
+ ? {
205
+ error: `This month's estimated Claude API spend, ${formatClaudeApiUsd(spentUsd)}, has reached the monthly cap of ${formatClaudeApiUsd(cap)} set in Settings → Providers → Claude API.`,
206
+ reason: `This month's estimated Claude API spend, ${formatClaudeApiUsd(spentUsd)}, has reached the monthly cap of ${formatClaudeApiUsd(cap)}.`,
207
+ }
208
+ : {}),
209
+ };
210
+ }
211
+
212
+ function modelLabel(model, settings) {
213
+ const detail = settings?.modelDetails?.[String(model).toLowerCase()];
214
+ return String(detail?.displayName || '').trim() || claudeAttributionModelLabel(model);
215
+ }
216
+
217
+ /** "$1.23", "$1.23 + unpriced tokens" or "no price known". */
218
+ function spendValue({ costUsd, unknownCostTokens }) {
219
+ if (unknownCostTokens > 0 && costUsd <= 0) return 'no price known';
220
+ return `${formatClaudeApiUsd(costUsd)}${unknownCostTokens > 0 ? ' (estimate incomplete)' : ''}`;
221
+ }
222
+
223
+ function incompleteText(unknownCostTokens) {
224
+ return `${formatTokens(unknownCostTokens)} tokens had no known price and are not in this figure`;
225
+ }
226
+
227
+ /**
228
+ * @param {object} options
229
+ * @param {object} options.settings claude-api-settings-service getSettings():
230
+ * `configured`, `enabled`, `monthlyCapUsd`, `modelDetails`
231
+ * @param {object|null} options.spend readClaudeApiSpend()
232
+ * @param {string|null} [options.resetAt] when the local month starts over (ISO)
233
+ * @param {string|null} [options.timeZone] the relay's zone name, for the note
234
+ * @param {string|null} [options.error] why the spend could not be read
235
+ */
236
+ export function buildClaudeApiPlanCard({
237
+ settings = null,
238
+ spend = null,
239
+ resetAt = null,
240
+ timeZone = null,
241
+ error = null,
242
+ } = {}) {
243
+ if (settings?.configured !== true && settings?.enabled !== true) return null;
244
+ const links = [
245
+ { label: 'Console billing', url: CLAUDE_API_CONSOLE_BILLING_URL },
246
+ { label: 'Console limits', url: CLAUDE_API_CONSOLE_LIMITS_URL },
247
+ ];
248
+ if (!spend) {
249
+ return buildProviderCard({
250
+ provider: CLAUDE_API_PROVIDER_TYPE,
251
+ label: CLAUDE_API_PROVIDER_LABEL,
252
+ status: STATUS_UNAVAILABLE,
253
+ message: `This relay's Claude API spend could not be read${error ? `: ${error}` : ''}.${capOf(settings?.monthlyCapUsd) !== null ? ' While a monthly cap is set, new turns on the key are refused until it can be read.' : ''}`,
254
+ source: SOURCE_WORKER,
255
+ links,
256
+ });
257
+ }
258
+ const { bounds, today, month } = spend;
259
+ const cap = claudeApiCapStateFrom({ capUsd: settings?.monthlyCapUsd, spend });
260
+ const zone = String(timeZone || '').trim();
261
+ const since = dayLabel(bounds.monthStart);
262
+
263
+ const messages = [];
264
+ if (settings?.enabled !== true) messages.push('Claude API is switched off; these are the turns it ran this month.');
265
+ if (cap.reached) {
266
+ messages.push(`This month's estimate has reached the cap: new Claude API turns, agents' new Claude API sessions and switches to the API key are refused until ${dayLabel(bounds.nextMonthStart)} or until the cap is raised. Turns already running finish, and their cost is added when they end.`);
267
+ }
268
+ if (!month.models.length) messages.push('No turns on the Claude API key this month yet.');
269
+
270
+ const meters = [];
271
+ if (cap.capUsd !== null) {
272
+ meters.push(buildMeter({
273
+ id: 'claude-api-monthly-cap',
274
+ label: 'Monthly cap',
275
+ unit: 'usd',
276
+ used: cap.spentUsd,
277
+ allowance: cap.capUsd,
278
+ remaining: roundCurrency(cap.capUsd - cap.spentUsd),
279
+ resetAt,
280
+ estimated: true,
281
+ note: [
282
+ `This relay's estimated spend since ${since}${zone ? ` (${zone})` : ''}; starts over on the 1st. At the cap, new Claude API turns are refused. A turn's cost is booked when it ends, so turns already running finish and can take the month past the cap.`,
283
+ cap.incomplete ? `Estimate incomplete: ${incompleteText(month.unknownCostTokens)}.` : '',
284
+ ].filter(Boolean).join(' '),
285
+ }));
286
+ }
287
+
288
+ const notes = [
289
+ {
290
+ id: 'claude-api-spend-today',
291
+ label: 'Estimated today',
292
+ value: spendValue(today),
293
+ hint: today.unknownCostTokens > 0 ? incompleteText(today.unknownCostTokens) : null,
294
+ text: `Estimated spend today: ${spendValue(today)}${today.unknownCostTokens > 0 ? ` (${incompleteText(today.unknownCostTokens)})` : ''}`,
295
+ },
296
+ {
297
+ id: 'claude-api-spend-month',
298
+ label: 'Estimated this month',
299
+ value: spendValue(month),
300
+ hint: [`since ${since}`, month.unknownCostTokens > 0 ? incompleteText(month.unknownCostTokens) : ''].filter(Boolean).join(', '),
301
+ text: `Estimated spend this month: ${spendValue(month)} since ${since}${month.unknownCostTokens > 0 ? ` (${incompleteText(month.unknownCostTokens)})` : ''}`,
302
+ },
303
+ {
304
+ id: 'claude-api-estimate',
305
+ // The card's links already name Console billing: not twice.
306
+ text: `Estimated by Claude Code from its own price table, for the turns on the saved key on this relay (sub-agents, compaction and web search included). Days and the month follow the relay's clock${zone ? ` (${zone})` : ''}. Turns paid from the subscriber API credit of a Max or Team plan are counted too; OAR cannot see that credit or the Console balance, and Console spend limits apply on top of the cap.`,
307
+ },
308
+ ];
309
+
310
+ const details = [];
311
+ if (month.models.length) {
312
+ details.push(buildDetailSection({
313
+ id: 'claude-api-spend-by-model',
314
+ label: 'Estimated spend by model, this month',
315
+ rows: month.models.map((entry) => {
316
+ const todayEntry = today.models.find((candidate) => candidate.model === entry.model);
317
+ return {
318
+ label: modelLabel(entry.model, settings),
319
+ value: spendValue(entry),
320
+ hint: [
321
+ todayEntry ? `today ${spendValue(todayEntry)}` : 'nothing today',
322
+ entry.unknownCostTokens > 0 ? incompleteText(entry.unknownCostTokens) : '',
323
+ ].filter(Boolean).join(' · '),
324
+ };
325
+ }),
326
+ note: 'Claude API turns on this relay only; the Console has every request on the key.',
327
+ open: true,
328
+ }));
329
+ details.push(buildDetailSection({
330
+ id: 'claude-api-tokens-by-model',
331
+ label: 'Tokens by model, this month',
332
+ rows: month.models.map((entry) => {
333
+ const input = entry.hitTokens + entry.missTokens;
334
+ return {
335
+ label: modelLabel(entry.model, settings),
336
+ value: `cache read ${formatTokens(entry.hitTokens)} · other input ${formatTokens(entry.missTokens)} · output ${formatTokens(entry.outputTokens)}`,
337
+ hint: input > 0 ? `${Math.round((entry.hitTokens / input) * 100)}% of input from the cache` : null,
338
+ };
339
+ }),
340
+ note: 'Other input includes the writes to the prompt cache.',
341
+ open: true,
342
+ }));
343
+ }
344
+
345
+ return buildProviderCard({
346
+ provider: CLAUDE_API_PROVIDER_TYPE,
347
+ label: CLAUDE_API_PROVIDER_LABEL,
348
+ status: STATUS_OK,
349
+ message: messages.join(' ') || null,
350
+ source: SOURCE_WORKER,
351
+ capturedAt: spend.lastSeenAt || null,
352
+ meters: meters.filter(Boolean),
353
+ details: details.filter(Boolean),
354
+ notes,
355
+ links,
356
+ });
357
+ }
358
+
359
+ /**
360
+ * The card and the cap over the booking service and the settings. The days,
361
+ * and the zone the card names, are the booking service's own (`localDay`,
362
+ * `timeZone`), so the two never disagree.
363
+ */
364
+ export function createClaudeApiPlanUsage({
365
+ usageService,
366
+ getSettings = () => null,
367
+ now = () => Date.now(),
368
+ } = {}) {
369
+ if (!usageService) throw new Error('createClaudeApiPlanUsage requires the Claude API usage service');
370
+ const zoneName = String(usageService.timeZone || '').trim();
371
+
372
+ function readSpend() {
373
+ return readClaudeApiSpend({ usageService, now: now() });
374
+ }
375
+
376
+ /** `{ reached, capUsd, spentUsd, incomplete, error? }`; throws when the totals cannot be read. */
377
+ function getCapState() {
378
+ const capUsd = capOf(getSettings()?.monthlyCapUsd);
379
+ if (capUsd === null) return { reached: false, capUsd: null };
380
+ return claudeApiCapStateFrom({ capUsd, spend: readSpend() });
381
+ }
382
+
383
+ /** The Check Usage card, or null while Claude API has no key and is off. */
384
+ function buildCard() {
385
+ const settings = getSettings();
386
+ if (settings?.configured !== true && settings?.enabled !== true) return null;
387
+ let spend = null;
388
+ let error = null;
389
+ try {
390
+ spend = readSpend();
391
+ } catch (readError) {
392
+ error = String(readError?.message || readError);
393
+ }
394
+ const resetAt = spend ? claudeApiLocalDayStart(usageService.localDay, spend.bounds.nextMonthStart) : null;
395
+ return buildClaudeApiPlanCard({ settings, spend, resetAt, timeZone: zoneName, error });
396
+ }
397
+
398
+ return { readSpend, getCapState, buildCard };
399
+ }
400
+
401
+ /**
402
+ * Tells when the cap flips, either way: the composer's toggle and the
403
+ * usage-limit banner's switch button read a chat's billing state, whose
404
+ * `switchable` follows the cap, so the runtime re-announces the Claude chats'
405
+ * states then. `check(reason)` after anything that moves the cap (a booked
406
+ * report, the cap setting, the clock passing into a new month) returns
407
+ * `{ reached, changed }`; a cap that cannot be read counts as reached. The
408
+ * first check only sets where it stands.
409
+ */
410
+ export function createClaudeApiCapWatch({ getCapState, onChange = () => {}, logger = console } = {}) {
411
+ if (typeof getCapState !== 'function') throw new Error('createClaudeApiCapWatch requires getCapState');
412
+ let last = null;
413
+ function check(reason = '') {
414
+ let reached = true;
415
+ try { reached = getCapState()?.reached === true; } catch { reached = true; }
416
+ const changed = last !== null && reached !== last;
417
+ last = reached;
418
+ if (changed) {
419
+ try {
420
+ onChange({ reached, reason });
421
+ } catch (error) {
422
+ try { logger.warn?.(`[claude-api-cap] announcing the cap ${reached ? 'reached' : 'cleared'} failed: ${error?.message || error}`); } catch {}
423
+ }
424
+ }
425
+ return { reached, changed };
426
+ }
427
+ return { check };
428
+ }
429
+
430
+ /**
431
+ * Which chats already have the note for a self-started turn stopped on the
432
+ * key, per episode of the refusal (`episode`: the cap's month and value, or
433
+ * the refusal's code): later stops in the same episode stay quiet. An
434
+ * episode ends for every chat when the cap clears or the Claude API
435
+ * settings are saved, and for one chat when a turn of it on the key answers.
436
+ */
437
+ export function createClaudeApiStopNoteLedger() {
438
+ const noted = new Map();
439
+ return {
440
+ has: (conversationId, episode) => noted.get(String(conversationId || '')) === String(episode || ''),
441
+ note: (conversationId, episode) => { noted.set(String(conversationId || ''), String(episode || '')); },
442
+ clear: (conversationId) => { noted.delete(String(conversationId || '')); },
443
+ clearAll: () => { noted.clear(); },
444
+ };
445
+ }
@@ -9,7 +9,7 @@
9
9
  * while context usage describes one session's token window.
10
10
  *
11
11
  * Every provider normalizer in `plan-usage-<provider>.mjs` emits the same
12
- * `{ card: { meters, details, notes, links } }` shape so the browser renderer
12
+ * `{ card: { meters, charts, details, notes, links } }` shape so the browser renderer
13
13
  * stays provider-agnostic, and so a provider that can only report *some* of
14
14
  * its limits degrades to fewer meters rather than to a special-cased layout.
15
15
  */
@@ -148,7 +148,7 @@ export function toSeverity(value) {
148
148
  * @param {object} spec
149
149
  * @param {string} spec.id stable key, used by tests and DOM ids
150
150
  * @param {string} spec.label human label
151
- * @param {'credits'|'requests'|'usd'|'percent'|'tokens'} [spec.unit]
151
+ * @param {'credits'|'requests'|'usd'|'cny'|'percent'|'tokens'} [spec.unit]
152
152
  * @param {boolean} [spec.unlimited] plan grants unlimited use of this bucket
153
153
  * @param {boolean} [spec.estimated] derived/manual rather than provider-authoritative
154
154
  * @param {string} [spec.severity] the provider's own word for how close the limit is
@@ -201,25 +201,67 @@ export function buildMeter({
201
201
  };
202
202
  }
203
203
 
204
+ /**
205
+ * Hour ranges of a day as `[start, end)` whole hours (0–24), or null when the
206
+ * input is not a list of such pairs.
207
+ */
208
+ export function normalizeHourRanges(value) {
209
+ if (!Array.isArray(value) || !value.length) return null;
210
+ const ranges = [];
211
+ for (const entry of value) {
212
+ if (!Array.isArray(entry) || entry.length !== 2) return null;
213
+ const start = toFiniteNumber(entry[0]);
214
+ const end = toFiniteNumber(entry[1]);
215
+ if (start === null || end === null || !Number.isInteger(start) || !Number.isInteger(end)) return null;
216
+ if (start < 0 || end > 24 || start >= end) return null;
217
+ ranges.push([start, end]);
218
+ }
219
+ return ranges;
220
+ }
221
+
222
+ /** "01:00–04:00 and 06:00–10:00 UTC" for `[[1, 4], [6, 10]]`. */
223
+ export function formatUtcHourRanges(ranges) {
224
+ const list = normalizeHourRanges(ranges);
225
+ if (!list) return null;
226
+ const clock = (hour) => `${String(hour % 24).padStart(2, '0')}:00`;
227
+ const parts = list.map(([start, end]) => `${clock(start)}–${clock(end)}`);
228
+ const joined = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];
229
+ return `${joined} UTC`;
230
+ }
231
+
204
232
  /**
205
233
  * A line of text on the card itself (not inside a collapsed section), with an
206
234
  * optional link behind it. Same link rule as the card's footer: https only.
235
+ *
236
+ * `hours`: `{ utcRanges: [[start, end], …] }` when the text names hours of the
237
+ * day. The text itself carries them in UTC (`formatUtcHourRanges`), complete
238
+ * for any reader; the note then also carries the ranges as data, with the
239
+ * exact UTC wording, so the browser can show them in its own time zone.
207
240
  */
208
- export function buildCardNote({ id, text, link = null } = {}) {
241
+ export function buildCardNote({ id, text, link = null, hours = null, label = null, value = null, hint = null } = {}) {
209
242
  const noteId = toTrimmedString(id);
210
243
  const noteText = toTrimmedString(text);
211
244
  if (!noteId || !noteText) return null;
212
245
  const linkLabel = toTrimmedString(link?.label);
213
246
  const url = toTrimmedString(link?.url);
247
+ const utcRanges = normalizeHourRanges(hours?.utcRanges);
248
+ const utcText = utcRanges ? formatUtcHourRanges(utcRanges) : null;
249
+ // A note that is a figure (a balance, a total) may say so as a label and a
250
+ // value, with a hint; the card shows it as a row of figures, and `text`
251
+ // stays the whole sentence for every other reader.
252
+ const factLabel = toTrimmedString(label);
253
+ const factValue = toTrimmedString(value);
214
254
  return {
215
255
  id: noteId,
216
256
  text: noteText,
217
257
  link: linkLabel && url && /^https:\/\//i.test(url) ? { label: linkLabel, url } : null,
258
+ hours: utcRanges && noteText.includes(utcText) ? { utcRanges, utcText } : null,
259
+ ...(factLabel && factValue ? { label: factLabel, value: factValue, hint: toTrimmedString(hint) } : {}),
218
260
  };
219
261
  }
220
262
 
221
263
  /** A collapsible block of label/value rows shown under the meters. */
222
- export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
264
+ export function buildDetailSection({ id, label, rows = [], note = null, open = false } = {}) {
223
265
  const sectionId = toTrimmedString(id);
224
266
  const sectionLabel = toTrimmedString(label);
225
267
  if (!sectionId || !sectionLabel) return null;
@@ -232,7 +274,57 @@ export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
232
274
  })
233
275
  .filter(Boolean);
234
276
  if (!normalizedRows.length) return null;
235
- return { id: sectionId, label: sectionLabel, rows: normalizedRows, note: toTrimmedString(note) };
277
+ return { id: sectionId, label: sectionLabel, rows: normalizedRows, note: toTrimmedString(note), ...(open === true ? { open: true } : {}) };
278
+ }
279
+
280
+ export const CHART_KINDS = Object.freeze(['columns', 'bars']);
281
+
282
+ /**
283
+ * A small chart drawn by the browser from data, never from markup: stacked
284
+ * `columns` (one per item, e.g. days) or horizontal stacked `bars` (one per
285
+ * item, e.g. models). Every item holds one value per series, in `unit`
286
+ * (`buildMeter`'s units); the renderer draws the shares and writes the text
287
+ * equivalents, so the card payload carries numbers only.
288
+ *
289
+ * `series`: `[{ key, label }]` in the order the colours are assigned, which
290
+ * never changes between reads. `items`: `[{ key, label, values: { <series
291
+ * key>: number } }]`; a value that is missing or not a number counts as 0.
292
+ * `note`: one sentence under the chart. `summary`: a figure the chart adds
293
+ * up to, shown with it (e.g. "Cache saved about ¥1.20").
294
+ */
295
+ export function buildChart({ id, label, kind = 'columns', unit = 'credits', series = [], items = [], note = null, summary = null } = {}) {
296
+ const chartId = toTrimmedString(id);
297
+ const chartLabel = toTrimmedString(label);
298
+ if (!chartId || !chartLabel || !CHART_KINDS.includes(kind)) return null;
299
+ const normalizedSeries = (Array.isArray(series) ? series : [])
300
+ .map((entry) => ({ key: toTrimmedString(entry?.key), label: toTrimmedString(entry?.label) }))
301
+ .filter((entry) => entry.key && entry.label);
302
+ const keys = normalizedSeries.map((entry) => entry.key);
303
+ if (!keys.length || new Set(keys).size !== keys.length) return null;
304
+ const normalizedItems = (Array.isArray(items) ? items : [])
305
+ .map((item) => {
306
+ const itemKey = toTrimmedString(item?.key);
307
+ const itemLabel = toTrimmedString(item?.label);
308
+ if (!itemKey || !itemLabel) return null;
309
+ const values = {};
310
+ for (const key of keys) {
311
+ const value = toFiniteNumber(item?.values?.[key]);
312
+ values[key] = value === null || value < 0 ? 0 : value;
313
+ }
314
+ return { key: itemKey, label: itemLabel, values };
315
+ })
316
+ .filter(Boolean);
317
+ if (!normalizedItems.length) return null;
318
+ return {
319
+ id: chartId,
320
+ label: chartLabel,
321
+ kind,
322
+ unit: toTrimmedString(unit) || 'credits',
323
+ series: normalizedSeries,
324
+ items: normalizedItems,
325
+ note: toTrimmedString(note),
326
+ summary: toTrimmedString(summary),
327
+ };
236
328
  }
237
329
 
238
330
  export function buildProviderCard({
@@ -246,6 +338,7 @@ export function buildProviderCard({
246
338
  capturedAt = null,
247
339
  stale = false,
248
340
  meters = [],
341
+ charts = [],
249
342
  details = [],
250
343
  notes = [],
251
344
  links = [],
@@ -255,6 +348,7 @@ export function buildProviderCard({
255
348
  if (!providerId) return null;
256
349
 
257
350
  const normalizedMeters = (Array.isArray(meters) ? meters : []).filter(Boolean);
351
+ const normalizedCharts = (Array.isArray(charts) ? charts : []).filter(Boolean);
258
352
  const normalizedDetails = (Array.isArray(details) ? details : []).filter(Boolean);
259
353
  const normalizedNotes = (Array.isArray(notes) ? notes : []).map((note) => buildCardNote(note || {})).filter(Boolean);
260
354
  const normalizedLinks = (Array.isArray(links) ? links : [])
@@ -279,6 +373,7 @@ export function buildProviderCard({
279
373
  capturedAt: toIsoTimestamp(capturedAt),
280
374
  stale: stale === true,
281
375
  meters: normalizedMeters,
376
+ charts: normalizedCharts,
282
377
  details: normalizedDetails,
283
378
  notes: normalizedNotes,
284
379
  links: normalizedLinks,