@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- 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,
|