@oar-sh/oar 0.9.6 → 0.9.7
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/CHANGELOG.md +172 -0
- package/README.md +176 -22
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +33 -0
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +143 -4
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +344 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +83 -4
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +51 -0
- package/server/public/index.html +381 -7
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +120 -15
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +502 -72
- package/server/server-runtime.mjs +258 -25
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { execFile as nodeExecFile } from 'child_process';
|
|
4
|
+
|
|
5
|
+
import { normalizeGitHubRepoUrl } from '../../shared/claude-cloud/repo-url.mjs';
|
|
6
|
+
import { createGitChangesService } from './git-changes-service.mjs';
|
|
7
|
+
|
|
8
|
+
// What a folder's git checkout says about where it lives on GitHub: the
|
|
9
|
+
// remote, the branch, and how far the checkout is from what was pushed. The
|
|
10
|
+
// New Chat modal fills the repository and branch of a Claude Cloud chat from
|
|
11
|
+
// it and warns about work the cloud clone will not have (commits that were
|
|
12
|
+
// never pushed, uncommitted changes).
|
|
13
|
+
//
|
|
14
|
+
// Read-only: `git status` and `git remote get-url`, nothing else. The route
|
|
15
|
+
// validates the folder before calling in.
|
|
16
|
+
|
|
17
|
+
const GIT_COMMAND_TIMEOUT_MS = 15_000;
|
|
18
|
+
const DEFAULT_REMOTE = 'origin';
|
|
19
|
+
|
|
20
|
+
const NO_GIT = Object.freeze({
|
|
21
|
+
hasGit: false,
|
|
22
|
+
remoteUrl: null,
|
|
23
|
+
repoUrl: null,
|
|
24
|
+
slug: null,
|
|
25
|
+
branch: null,
|
|
26
|
+
upstream: null,
|
|
27
|
+
ahead: 0,
|
|
28
|
+
behind: 0,
|
|
29
|
+
dirty: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A remote URL as the browser may see it: credentials in front of the host
|
|
34
|
+
* (`https://user:secret@host/…`) are dropped. The scp form keeps its `git@`.
|
|
35
|
+
*/
|
|
36
|
+
export function redactRemoteUrlCredentials(remoteUrl) {
|
|
37
|
+
const text = String(remoteUrl || '').trim();
|
|
38
|
+
if (!text) return '';
|
|
39
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text)) return text;
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(text);
|
|
42
|
+
if (!url.username && !url.password) return text;
|
|
43
|
+
// ssh://git@host/… names the transport user, not a secret.
|
|
44
|
+
if (url.protocol === 'ssh:' && !url.password) return text;
|
|
45
|
+
url.username = '';
|
|
46
|
+
url.password = '';
|
|
47
|
+
return url.toString();
|
|
48
|
+
} catch {
|
|
49
|
+
return text.replace(/\/\/[^/@\s]*@/, '//');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The remote a tracking ref such as `fork/main` lives on, or ''. */
|
|
54
|
+
function remoteOfUpstream(upstream) {
|
|
55
|
+
const text = String(upstream || '').trim();
|
|
56
|
+
const slash = text.indexOf('/');
|
|
57
|
+
return slash > 0 ? text.slice(0, slash) : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createGitRemoteService({
|
|
61
|
+
execFileImpl = nodeExecFile,
|
|
62
|
+
gitChangesService = createGitChangesService({ execFileImpl }),
|
|
63
|
+
} = {}) {
|
|
64
|
+
function readRemoteUrl(rootPath, remoteName) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
try {
|
|
67
|
+
execFileImpl('git', ['remote', 'get-url', remoteName], {
|
|
68
|
+
cwd: rootPath,
|
|
69
|
+
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
}, (error, stdout) => {
|
|
72
|
+
// No such remote is an answer, not a failure.
|
|
73
|
+
resolve(error ? '' : String(stdout || '').trim().split(/\r?\n/)[0] || '');
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
resolve('');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `{ ok, hasGit, remoteUrl, repoUrl, slug, branch, upstream, ahead, behind,
|
|
83
|
+
* dirty }` for a folder; `hasGit: false` when it is not a git checkout.
|
|
84
|
+
*/
|
|
85
|
+
async function describe(rootPath) {
|
|
86
|
+
const status = await gitChangesService.getStatus(rootPath);
|
|
87
|
+
if (!status?.ok) return { ok: false, error: status?.error || 'Failed to read git status' };
|
|
88
|
+
if (status.isRepo === false) return { ok: true, ...NO_GIT };
|
|
89
|
+
|
|
90
|
+
const upstream = String(status.upstream || '').trim();
|
|
91
|
+
let remoteUrl = await readRemoteUrl(rootPath, DEFAULT_REMOTE);
|
|
92
|
+
const upstreamRemote = remoteOfUpstream(upstream);
|
|
93
|
+
if (!remoteUrl && upstreamRemote && upstreamRemote !== DEFAULT_REMOTE) {
|
|
94
|
+
remoteUrl = await readRemoteUrl(rootPath, upstreamRemote);
|
|
95
|
+
}
|
|
96
|
+
// One validator for the modal and for the bootstrap that follows it: a
|
|
97
|
+
// remote the bootstrap would refuse is shown as "no GitHub remote".
|
|
98
|
+
const repo = normalizeGitHubRepoUrl(remoteUrl);
|
|
99
|
+
const branch = status.detached ? '' : String(status.branch || '').trim();
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
hasGit: true,
|
|
103
|
+
remoteUrl: redactRemoteUrlCredentials(remoteUrl) || null,
|
|
104
|
+
repoUrl: repo?.repoUrl || null,
|
|
105
|
+
slug: repo?.slug || null,
|
|
106
|
+
branch: branch || null,
|
|
107
|
+
upstream: upstream || null,
|
|
108
|
+
ahead: Number(status.ahead || 0),
|
|
109
|
+
behind: Number(status.behind || 0),
|
|
110
|
+
dirty: Array.isArray(status.files) && status.files.length > 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { describe };
|
|
115
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Cloud usage (provider `claude-cloud`).
|
|
5
|
+
*
|
|
6
|
+
* Cloud sessions are billed to the Claude account: a dollar credit is spent
|
|
7
|
+
* first where the account holds one, and without it the turns count against
|
|
8
|
+
* the same 5-hour and weekly windows the Claude card shows. So this card has
|
|
9
|
+
* no windows of its own. It shows
|
|
10
|
+
* - the dollar buckets of the live account usage (the credit and its expiry),
|
|
11
|
+
* - what the relay's own cloud conversations have cost, as Anthropic reported
|
|
12
|
+
* it per session (the relay keeps the latest figure per conversation; there
|
|
13
|
+
* is no account-wide total to ask for),
|
|
14
|
+
* - the session detail of the latest cloud worker report.
|
|
15
|
+
*
|
|
16
|
+
* The credit arrives under an internal codename that may change, so every
|
|
17
|
+
* top-level bucket of the usage body that carries a dollar limit is a meter;
|
|
18
|
+
* the codename table only supplies a nicer label.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
SOURCE_LIVE,
|
|
23
|
+
SOURCE_WORKER,
|
|
24
|
+
STATUS_OK,
|
|
25
|
+
STATUS_PARTIAL,
|
|
26
|
+
STATUS_UNAVAILABLE,
|
|
27
|
+
buildDetailSection,
|
|
28
|
+
buildMeter,
|
|
29
|
+
buildProviderCard,
|
|
30
|
+
pickNonNegative,
|
|
31
|
+
toFiniteNumber,
|
|
32
|
+
toIsoTimestamp,
|
|
33
|
+
toTrimmedString,
|
|
34
|
+
} from './plan-usage-contract.mjs';
|
|
35
|
+
import { CLAUDE_USAGE_URL, buildClaudeSessionSections, formatClaudeMoney } from './plan-usage-claude.mjs';
|
|
36
|
+
|
|
37
|
+
export const CLAUDE_CLOUD_PROVIDER_ID = 'claude-cloud';
|
|
38
|
+
export const CLAUDE_CLOUD_LABEL = 'Claude Cloud';
|
|
39
|
+
|
|
40
|
+
const CLAUDE_CODE_WEB_URL = 'https://claude.ai/code';
|
|
41
|
+
|
|
42
|
+
// Null-prototype: the keys come from an API body.
|
|
43
|
+
const DOLLAR_BUCKET_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
44
|
+
iguana_necktie: 'Cloud sessions credit',
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
/** "maple_sprocket" → "Maple sprocket credit": the label of a bucket the table does not know. */
|
|
48
|
+
export function humanizeDollarBucketKey(key) {
|
|
49
|
+
const words = String(key ?? '').replace(/[^A-Za-z0-9]+/g, ' ').trim().toLowerCase();
|
|
50
|
+
if (!words) return 'Credit';
|
|
51
|
+
return `${words.charAt(0).toUpperCase()}${words.slice(1)} credit`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The dollar buckets of an account usage body, in the body's order: every
|
|
56
|
+
* top-level value that is an object with a numeric `limit_dollars`.
|
|
57
|
+
*/
|
|
58
|
+
export function listClaudeDollarBuckets(usageBody) {
|
|
59
|
+
if (!usageBody || typeof usageBody !== 'object' || Array.isArray(usageBody)) return [];
|
|
60
|
+
const buckets = [];
|
|
61
|
+
for (const [key, value] of Object.entries(usageBody)) {
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
63
|
+
const limitUsd = toFiniteNumber(value.limit_dollars);
|
|
64
|
+
if (limitUsd === null) continue;
|
|
65
|
+
buckets.push({
|
|
66
|
+
key,
|
|
67
|
+
label: DOLLAR_BUCKET_LABELS[key] || humanizeDollarBucketKey(key),
|
|
68
|
+
limitUsd,
|
|
69
|
+
usedUsd: toFiniteNumber(value.used_dollars),
|
|
70
|
+
remainingUsd: toFiniteNumber(value.remaining_dollars),
|
|
71
|
+
utilization: toFiniteNumber(value.utilization),
|
|
72
|
+
expiresAt: toIsoTimestamp(value.resets_at),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return buckets;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What the credit-offer answer says: `{ open, amount }`, where `amount` is
|
|
80
|
+
* the formatted sum when the answer names one. An offer that was granted
|
|
81
|
+
* already is not open.
|
|
82
|
+
*/
|
|
83
|
+
export function describeClaudeCreditOffer(offer) {
|
|
84
|
+
if (!offer || typeof offer !== 'object') return { open: false, amount: null };
|
|
85
|
+
const open = (offer.available === true || offer.eligible === true) && offer.granted !== true;
|
|
86
|
+
const minor = toFiniteNumber(offer.amount_minor_units);
|
|
87
|
+
const amount = minor !== null && minor > 0 ? formatClaudeMoney(minor / 100, offer.currency) : null;
|
|
88
|
+
return { open, amount };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {object} options
|
|
93
|
+
* @param {object|null} options.account live account usage (`{ usage, prepaid,
|
|
94
|
+
* offer, fetchedAt, error }`), or null when it was not read
|
|
95
|
+
* @param {object|null} options.spend what the relay recorded: `{ totalUsd,
|
|
96
|
+
* conversationCount, conversation: { costUsd } | null }` (`conversation` is
|
|
97
|
+
* the one the modal was opened from, when it is a cloud conversation)
|
|
98
|
+
* @param {object|null} options.usage the latest `claude-cloud` snapshot
|
|
99
|
+
* @param {string|null} options.capturedAt when that snapshot was stored
|
|
100
|
+
* @param {boolean} options.configured Claude Cloud provider enabled
|
|
101
|
+
*/
|
|
102
|
+
export function buildClaudeCloudPlanCard({
|
|
103
|
+
account = null,
|
|
104
|
+
spend = null,
|
|
105
|
+
usage = null,
|
|
106
|
+
capturedAt = null,
|
|
107
|
+
configured = true,
|
|
108
|
+
} = {}) {
|
|
109
|
+
// Opt-in like Grok: no card at all while the provider is off.
|
|
110
|
+
if (!configured) return null;
|
|
111
|
+
|
|
112
|
+
const hasAccount = !!account?.usage && typeof account.usage === 'object';
|
|
113
|
+
const buckets = hasAccount ? listClaudeDollarBuckets(account.usage) : [];
|
|
114
|
+
const meters = buckets.map((bucket) => buildMeter({
|
|
115
|
+
id: `claude-cloud-credit-${bucket.key.replace(/[^A-Za-z0-9_]+/g, '-')}`,
|
|
116
|
+
label: bucket.label,
|
|
117
|
+
unit: 'usd',
|
|
118
|
+
used: bucket.usedUsd,
|
|
119
|
+
allowance: bucket.limitUsd,
|
|
120
|
+
remaining: bucket.remainingUsd,
|
|
121
|
+
// The dollars are exact; the percentage only stands in when they are missing.
|
|
122
|
+
utilization: bucket.usedUsd === null && bucket.remainingUsd === null ? bucket.utilization : null,
|
|
123
|
+
resetAt: bucket.expiresAt,
|
|
124
|
+
resetKind: 'expiry',
|
|
125
|
+
emphasis: 'primary',
|
|
126
|
+
})).filter(Boolean);
|
|
127
|
+
const hasCredit = meters.length > 0;
|
|
128
|
+
|
|
129
|
+
const details = [];
|
|
130
|
+
const conversationCount = Math.max(0, Math.round(toFiniteNumber(spend?.conversationCount) || 0));
|
|
131
|
+
if (conversationCount > 0) {
|
|
132
|
+
const totalUsd = pickNonNegative(spend?.totalUsd) ?? 0;
|
|
133
|
+
const countText = `${conversationCount} cloud conversation${conversationCount === 1 ? '' : 's'}`;
|
|
134
|
+
meters.push(buildMeter({
|
|
135
|
+
id: 'claude-cloud-spend',
|
|
136
|
+
label: 'Cloud spend (OAR sessions)',
|
|
137
|
+
unit: 'usd',
|
|
138
|
+
used: totalUsd,
|
|
139
|
+
emphasis: hasCredit ? 'secondary' : 'primary',
|
|
140
|
+
note: `${countText} on this relay. Anthropic’s reported cost, not a billing statement.`,
|
|
141
|
+
}));
|
|
142
|
+
const rows = [{ label: 'All cloud conversations', value: formatClaudeMoney(totalUsd), hint: countText }];
|
|
143
|
+
if (spend?.conversation && typeof spend.conversation === 'object') {
|
|
144
|
+
const cost = pickNonNegative(spend.conversation.costUsd);
|
|
145
|
+
rows.push({
|
|
146
|
+
label: 'This conversation',
|
|
147
|
+
value: cost === null ? '—' : formatClaudeMoney(cost),
|
|
148
|
+
hint: cost === null ? 'No cost reported yet' : null,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
details.push(buildDetailSection({
|
|
152
|
+
id: 'claude-cloud-spend',
|
|
153
|
+
label: 'Cloud spend (OAR sessions)',
|
|
154
|
+
note: 'The cost Anthropic reports per cloud session, summed over the cloud conversations this relay still has. Not a billing statement; sessions started elsewhere are not included.',
|
|
155
|
+
rows,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
details.push(...buildClaudeSessionSections(usage?.session, {
|
|
160
|
+
idPrefix: 'claude-cloud',
|
|
161
|
+
sessionLabel: 'Latest cloud session report',
|
|
162
|
+
modelsLabel: 'By model (latest cloud session report)',
|
|
163
|
+
costLabel: 'Reported cost',
|
|
164
|
+
costHint: 'Anthropic’s reported cost, not a billing statement',
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const notes = [];
|
|
168
|
+
const offer = describeClaudeCreditOffer(account?.offer);
|
|
169
|
+
if (!hasCredit && offer.open) {
|
|
170
|
+
notes.push({
|
|
171
|
+
id: 'claude-cloud-credit-offer',
|
|
172
|
+
text: offer.amount
|
|
173
|
+
? `A cloud credit of ${offer.amount} is available to claim on claude.ai.`
|
|
174
|
+
: 'A cloud credit is available to claim on claude.ai.',
|
|
175
|
+
link: { label: 'Open the usage page', url: CLAUDE_USAGE_URL },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
notes.push({
|
|
179
|
+
id: 'claude-cloud-billing',
|
|
180
|
+
text: 'Cloud turns spend the cloud credit first; without a credit they count against the Claude limits (5-hour and weekly).',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const resolvedMeters = meters.filter(Boolean);
|
|
184
|
+
const resolvedDetails = details.filter(Boolean);
|
|
185
|
+
let status = STATUS_OK;
|
|
186
|
+
let message = null;
|
|
187
|
+
if (!hasAccount) {
|
|
188
|
+
status = resolvedMeters.length || resolvedDetails.length ? STATUS_PARTIAL : STATUS_UNAVAILABLE;
|
|
189
|
+
message = ['The account usage could not be read, so a cloud credit is not shown.', toTrimmedString(account?.error)]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' ');
|
|
192
|
+
} else if (!resolvedMeters.length) {
|
|
193
|
+
message = 'This account holds no cloud credit, and no cloud conversation on this relay has run yet.';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return buildProviderCard({
|
|
197
|
+
provider: CLAUDE_CLOUD_PROVIDER_ID,
|
|
198
|
+
label: CLAUDE_CLOUD_LABEL,
|
|
199
|
+
status,
|
|
200
|
+
message,
|
|
201
|
+
source: hasAccount ? SOURCE_LIVE : SOURCE_WORKER,
|
|
202
|
+
capturedAt: hasAccount ? (toIsoTimestamp(account.fetchedAt) || capturedAt) : capturedAt,
|
|
203
|
+
meters: resolvedMeters,
|
|
204
|
+
details: resolvedDetails,
|
|
205
|
+
notes,
|
|
206
|
+
links: [
|
|
207
|
+
{ label: 'Claude usage settings', url: CLAUDE_USAGE_URL },
|
|
208
|
+
{ label: 'Claude Code on the web', url: CLAUDE_CODE_WEB_URL },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
* When the experimental call is unavailable the worker still reports the
|
|
14
14
|
* stable `modelUsage` / `total_cost_usd` result fields, which render as session
|
|
15
15
|
* cost details with no plan meters.
|
|
16
|
+
*
|
|
17
|
+
* A second, live source exists while the Claude Cloud provider is switched on
|
|
18
|
+
* (the switch is the consent to use the Claude CLI's login): the account's
|
|
19
|
+
* usage as claude.ai's own endpoint reports it, read without a model turn
|
|
20
|
+
* (`claude-account-usage-service.mjs`). It carries the same window keys, so
|
|
21
|
+
* the meters are then built from it and the SDK snapshot only supplies the
|
|
22
|
+
* session details.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import {
|
|
19
26
|
SOURCE_CACHE,
|
|
27
|
+
SOURCE_LIVE,
|
|
20
28
|
SOURCE_WORKER,
|
|
21
29
|
STATUS_NOT_CONFIGURED,
|
|
22
30
|
STATUS_OK,
|
|
@@ -30,13 +38,14 @@ import {
|
|
|
30
38
|
roundCurrency,
|
|
31
39
|
toFiniteNumber,
|
|
32
40
|
toIsoTimestamp,
|
|
41
|
+
toSeverity,
|
|
33
42
|
toTrimmedString,
|
|
34
43
|
} from './plan-usage-contract.mjs';
|
|
35
44
|
|
|
36
45
|
export const CLAUDE_PROVIDER_ID = 'claude';
|
|
37
46
|
export const CLAUDE_LABEL = 'Claude';
|
|
38
47
|
|
|
39
|
-
const CLAUDE_USAGE_URL = 'https://claude.ai/settings/usage';
|
|
48
|
+
export const CLAUDE_USAGE_URL = 'https://claude.ai/settings/usage';
|
|
40
49
|
|
|
41
50
|
// Fixed windows, in display order. Model-scoped windows are appended after
|
|
42
51
|
// these from the server-supplied `model_scoped[]` array.
|
|
@@ -139,7 +148,7 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
139
148
|
const displayName = toTrimmedString(entry?.display_name);
|
|
140
149
|
if (!normalized || !displayName) continue;
|
|
141
150
|
windows.push({
|
|
142
|
-
id:
|
|
151
|
+
id: modelScopedWindowId(displayName),
|
|
143
152
|
label: `Weekly ${displayName}`,
|
|
144
153
|
emphasis: 'secondary',
|
|
145
154
|
...normalized,
|
|
@@ -157,6 +166,7 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
157
166
|
usedCredits: toFiniteNumber(rawExtra.used_credits),
|
|
158
167
|
utilization: clampPercent(rawExtra.utilization),
|
|
159
168
|
currency: toTrimmedString(rawExtra.currency),
|
|
169
|
+
decimalPlaces: toFiniteNumber(rawExtra.decimal_places),
|
|
160
170
|
}
|
|
161
171
|
: null;
|
|
162
172
|
|
|
@@ -184,6 +194,75 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
184
194
|
return hasSignal ? payload : null;
|
|
185
195
|
}
|
|
186
196
|
|
|
197
|
+
function modelScopedWindowId(displayName) {
|
|
198
|
+
return `model_scoped:${displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `limits[]` of the account endpoint names the two fixed windows by kind.
|
|
202
|
+
const LIMIT_KIND_WINDOW_IDS = Object.freeze({ session: 'five_hour', weekly_all: 'seven_day' });
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Normalize the live account usage (the body of claude.ai's usage endpoint).
|
|
206
|
+
*
|
|
207
|
+
* Its top level is what the SDK response carries under `rate_limits`, so the
|
|
208
|
+
* windows and the extra usage come from the same normalizer. On top of that:
|
|
209
|
+
* `limits[]` adds a severity per window and the model-scoped weekly windows,
|
|
210
|
+
* and `seven_day_breakdown.rows` says which product the week went to.
|
|
211
|
+
* Returns `{ windows, extraUsage, breakdown }`, or null when nothing usable
|
|
212
|
+
* is present.
|
|
213
|
+
*/
|
|
214
|
+
export function normalizeClaudeAccountUsage(body) {
|
|
215
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
216
|
+
const base = normalizeClaudePlanUsage({ rate_limits: body, rate_limits_available: true });
|
|
217
|
+
const windows = (base?.windows || []).map((window) => ({ ...window, severity: null }));
|
|
218
|
+
|
|
219
|
+
for (const entry of (Array.isArray(body.limits) ? body.limits : [])) {
|
|
220
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
221
|
+
const kind = toTrimmedString(entry.kind);
|
|
222
|
+
let target = null;
|
|
223
|
+
if (kind === 'weekly_scoped') {
|
|
224
|
+
const displayName = toTrimmedString(entry.scope?.model?.display_name)
|
|
225
|
+
|| toTrimmedString(entry.scope?.surface?.display_name);
|
|
226
|
+
if (!displayName) continue;
|
|
227
|
+
const label = `Weekly ${displayName}`;
|
|
228
|
+
target = windows.find((window) => window.label.toLowerCase() === label.toLowerCase()) || null;
|
|
229
|
+
if (!target) {
|
|
230
|
+
const reading = normalizeWindow({ utilization: entry.percent, resets_at: entry.resets_at });
|
|
231
|
+
if (!reading) continue;
|
|
232
|
+
target = { id: modelScopedWindowId(displayName), label, emphasis: 'secondary', ...reading, severity: null };
|
|
233
|
+
windows.push(target);
|
|
234
|
+
}
|
|
235
|
+
} else if (kind && LIMIT_KIND_WINDOW_IDS[kind]) {
|
|
236
|
+
target = windows.find((window) => window.id === LIMIT_KIND_WINDOW_IDS[kind]) || null;
|
|
237
|
+
}
|
|
238
|
+
if (target) target.severity = toSeverity(entry.severity);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const breakdown = (Array.isArray(body.seven_day_breakdown?.rows) ? body.seven_day_breakdown.rows : [])
|
|
242
|
+
.map((row) => {
|
|
243
|
+
const name = toTrimmedString(row?.display_name) || toTrimmedString(row?.key);
|
|
244
|
+
const percent = clampPercent(row?.percent);
|
|
245
|
+
return name && percent !== null ? { name, percent } : null;
|
|
246
|
+
})
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
|
|
249
|
+
const extraUsage = base?.extraUsage || null;
|
|
250
|
+
if (!windows.length && !extraUsage && !breakdown.length) return null;
|
|
251
|
+
return { windows, extraUsage, breakdown };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The prepaid credits of the organisation as `{ amount, currency }` in major
|
|
256
|
+
* units, or null. The API sends the balance as `amount` in minor units (the
|
|
257
|
+
* Claude CLI reads it as cents).
|
|
258
|
+
*/
|
|
259
|
+
export function normalizeClaudePrepaidCredits(raw) {
|
|
260
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
261
|
+
const minor = toFiniteNumber(raw.amount);
|
|
262
|
+
if (minor === null) return null;
|
|
263
|
+
return { amount: minor / 100, currency: toTrimmedString(raw.currency) };
|
|
264
|
+
}
|
|
265
|
+
|
|
187
266
|
/** Build the fallback payload from the stable result-message fields. */
|
|
188
267
|
export function claudePlanUsageFromResult({ modelUsage = null, totalCostUsd = null } = {}) {
|
|
189
268
|
const models = normalizeModelUsageMap(modelUsage);
|
|
@@ -232,6 +311,68 @@ function formatUsd(value) {
|
|
|
232
311
|
return numeric === null ? null : `$${numeric.toFixed(2)}`;
|
|
233
312
|
}
|
|
234
313
|
|
|
314
|
+
/** "$12.50" for dollars (or an unnamed currency), "12.50 EUR" for any other. */
|
|
315
|
+
export function formatClaudeMoney(amount, currency = null) {
|
|
316
|
+
const numeric = roundCurrency(amount);
|
|
317
|
+
if (numeric === null) return null;
|
|
318
|
+
const code = toTrimmedString(currency)?.toUpperCase() || 'USD';
|
|
319
|
+
return code === 'USD' ? `$${numeric.toFixed(2)}` : `${numeric.toFixed(2)} ${code}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extra-usage amounts arrive in minor units of the currency (cents for USD;
|
|
324
|
+
* the SDK's response schema says so, and the account endpoint names the
|
|
325
|
+
* exponent in `decimal_places`).
|
|
326
|
+
*/
|
|
327
|
+
function extraUsageAmount(value, decimalPlaces) {
|
|
328
|
+
const numeric = toFiniteNumber(value);
|
|
329
|
+
if (numeric === null) return null;
|
|
330
|
+
const places = Number.isInteger(decimalPlaces) && decimalPlaces >= 0 && decimalPlaces <= 4 ? decimalPlaces : 2;
|
|
331
|
+
return numeric / (10 ** places);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The detail sections for one session's totals (`session` as
|
|
336
|
+
* `normalizeClaudePlanUsage` / `claudePlanUsageFromResult` store it). The
|
|
337
|
+
* Claude Cloud card shows its own latest snapshot through the same sections,
|
|
338
|
+
* under its own ids and wording.
|
|
339
|
+
*/
|
|
340
|
+
export function buildClaudeSessionSections(session, {
|
|
341
|
+
idPrefix = 'claude',
|
|
342
|
+
sessionLabel = 'Latest session totals',
|
|
343
|
+
modelsLabel = 'By model (session)',
|
|
344
|
+
costLabel = 'Session cost (estimate)',
|
|
345
|
+
costHint = 'Client-side estimate, not a billing statement',
|
|
346
|
+
} = {}) {
|
|
347
|
+
if (!session || typeof session !== 'object') return [];
|
|
348
|
+
const sections = [];
|
|
349
|
+
const rows = [];
|
|
350
|
+
const cost = formatUsd(session.totalCostUsd);
|
|
351
|
+
if (cost) rows.push({ label: costLabel, value: cost, hint: costHint });
|
|
352
|
+
const apiDuration = formatDuration(session.totalApiDurationMs);
|
|
353
|
+
if (apiDuration) rows.push({ label: 'API time', value: apiDuration });
|
|
354
|
+
const wallDuration = formatDuration(session.totalDurationMs);
|
|
355
|
+
if (wallDuration) rows.push({ label: 'Wall-clock time', value: wallDuration });
|
|
356
|
+
if (toFiniteNumber(session.totalLinesAdded) !== null) rows.push({ label: 'Lines added', value: String(session.totalLinesAdded) });
|
|
357
|
+
if (toFiniteNumber(session.totalLinesRemoved) !== null) rows.push({ label: 'Lines removed', value: String(session.totalLinesRemoved) });
|
|
358
|
+
const sessionSection = buildDetailSection({ id: `${idPrefix}-session`, label: sessionLabel, rows });
|
|
359
|
+
if (sessionSection) sections.push(sessionSection);
|
|
360
|
+
|
|
361
|
+
const modelRows = (Array.isArray(session.modelUsage) ? session.modelUsage : []).map((entry) => ({
|
|
362
|
+
label: entry.model,
|
|
363
|
+
value: formatUsd(entry.costUsd) ?? '—',
|
|
364
|
+
hint: [
|
|
365
|
+
formatTokens(entry.inputTokens) ? `in ${formatTokens(entry.inputTokens)}` : null,
|
|
366
|
+
formatTokens(entry.outputTokens) ? `out ${formatTokens(entry.outputTokens)}` : null,
|
|
367
|
+
formatTokens(entry.cacheReadTokens) ? `cache r ${formatTokens(entry.cacheReadTokens)}` : null,
|
|
368
|
+
formatTokens(entry.cacheWriteTokens) ? `cache w ${formatTokens(entry.cacheWriteTokens)}` : null,
|
|
369
|
+
].filter(Boolean).join(' · ') || null,
|
|
370
|
+
}));
|
|
371
|
+
const modelSection = buildDetailSection({ id: `${idPrefix}-models`, label: modelsLabel, rows: modelRows });
|
|
372
|
+
if (modelSection) sections.push(modelSection);
|
|
373
|
+
return sections;
|
|
374
|
+
}
|
|
375
|
+
|
|
235
376
|
function attributionRows(entries, limit = 6) {
|
|
236
377
|
return entries.slice(0, limit).map((entry) => ({
|
|
237
378
|
label: entry.name,
|
|
@@ -280,12 +421,20 @@ function behaviorSections(behaviors) {
|
|
|
280
421
|
return sections;
|
|
281
422
|
}
|
|
282
423
|
|
|
424
|
+
/**
|
|
425
|
+
* @param {object} options
|
|
426
|
+
* @param {object|null} options.usage the stored snapshot of the last SDK turn
|
|
427
|
+
* @param {object|null} options.account live account usage
|
|
428
|
+
* (`{ usage, prepaid, offer, fetchedAt, error }` from
|
|
429
|
+
* claude-account-usage-service.mjs), or null when it was not read
|
|
430
|
+
*/
|
|
283
431
|
export function buildClaudePlanCard({
|
|
284
432
|
usage = null,
|
|
285
433
|
capturedAt = null,
|
|
286
434
|
stale = false,
|
|
287
435
|
configured = true,
|
|
288
436
|
message = null,
|
|
437
|
+
account = null,
|
|
289
438
|
} = {}) {
|
|
290
439
|
if (!configured) {
|
|
291
440
|
return buildUnavailableCard({
|
|
@@ -295,7 +444,11 @@ export function buildClaudePlanCard({
|
|
|
295
444
|
message: 'Claude is not enabled in provider settings.',
|
|
296
445
|
});
|
|
297
446
|
}
|
|
298
|
-
|
|
447
|
+
const live = normalizeClaudeAccountUsage(account?.usage);
|
|
448
|
+
// Live windows replace the snapshot's; a live answer without a single
|
|
449
|
+
// window (it should not happen for a plan login) leaves the snapshot in place.
|
|
450
|
+
const hasLive = !!live && live.windows.length > 0;
|
|
451
|
+
if (!usage && !hasLive) {
|
|
299
452
|
return buildUnavailableCard({
|
|
300
453
|
provider: CLAUDE_PROVIDER_ID,
|
|
301
454
|
label: CLAUDE_LABEL,
|
|
@@ -307,7 +460,8 @@ export function buildClaudePlanCard({
|
|
|
307
460
|
}
|
|
308
461
|
|
|
309
462
|
const meters = [];
|
|
310
|
-
|
|
463
|
+
const windows = hasLive ? live.windows : (Array.isArray(usage.windows) ? usage.windows : []);
|
|
464
|
+
for (const window of windows) {
|
|
311
465
|
meters.push(buildMeter({
|
|
312
466
|
id: `claude-${window.id}`,
|
|
313
467
|
label: window.label,
|
|
@@ -315,17 +469,18 @@ export function buildClaudePlanCard({
|
|
|
315
469
|
utilization: window.utilization,
|
|
316
470
|
resetAt: window.resetsAt,
|
|
317
471
|
emphasis: window.emphasis === 'secondary' ? 'secondary' : 'primary',
|
|
472
|
+
severity: window.severity,
|
|
318
473
|
}));
|
|
319
474
|
}
|
|
320
475
|
|
|
321
|
-
const extra = usage.extraUsage;
|
|
476
|
+
const extra = hasLive ? live.extraUsage : usage.extraUsage;
|
|
322
477
|
if (extra && (extra.isEnabled || extra.usedCredits !== null || extra.monthlyLimit !== null)) {
|
|
323
478
|
meters.push(buildMeter({
|
|
324
479
|
id: 'claude-extra-usage',
|
|
325
480
|
label: 'Extra usage credits',
|
|
326
481
|
unit: extra.currency && extra.currency.toUpperCase() !== 'USD' ? 'credits' : 'usd',
|
|
327
|
-
used: extra.usedCredits,
|
|
328
|
-
allowance: extra.monthlyLimit,
|
|
482
|
+
used: extraUsageAmount(extra.usedCredits, extra.decimalPlaces),
|
|
483
|
+
allowance: extraUsageAmount(extra.monthlyLimit, extra.decimalPlaces),
|
|
329
484
|
utilization: extra.utilization,
|
|
330
485
|
note: extra.isEnabled ? null : 'Extra usage is disabled for this account',
|
|
331
486
|
emphasis: 'secondary',
|
|
@@ -333,55 +488,62 @@ export function buildClaudePlanCard({
|
|
|
333
488
|
}
|
|
334
489
|
|
|
335
490
|
const details = [];
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (apiDuration) rows.push({ label: 'API time', value: apiDuration });
|
|
343
|
-
const wallDuration = formatDuration(session.totalDurationMs);
|
|
344
|
-
if (wallDuration) rows.push({ label: 'Wall-clock time', value: wallDuration });
|
|
345
|
-
if (session.totalLinesAdded !== null) rows.push({ label: 'Lines added', value: String(session.totalLinesAdded) });
|
|
346
|
-
if (session.totalLinesRemoved !== null) rows.push({ label: 'Lines removed', value: String(session.totalLinesRemoved) });
|
|
347
|
-
const sessionSection = buildDetailSection({ id: 'claude-session', label: 'Latest session totals', rows });
|
|
348
|
-
if (sessionSection) details.push(sessionSection);
|
|
349
|
-
|
|
350
|
-
const modelRows = (session.modelUsage || []).map((entry) => ({
|
|
351
|
-
label: entry.model,
|
|
352
|
-
value: formatUsd(entry.costUsd) ?? '—',
|
|
353
|
-
hint: [
|
|
354
|
-
formatTokens(entry.inputTokens) ? `in ${formatTokens(entry.inputTokens)}` : null,
|
|
355
|
-
formatTokens(entry.outputTokens) ? `out ${formatTokens(entry.outputTokens)}` : null,
|
|
356
|
-
formatTokens(entry.cacheReadTokens) ? `cache r ${formatTokens(entry.cacheReadTokens)}` : null,
|
|
357
|
-
formatTokens(entry.cacheWriteTokens) ? `cache w ${formatTokens(entry.cacheWriteTokens)}` : null,
|
|
358
|
-
].filter(Boolean).join(' · ') || null,
|
|
491
|
+
if (hasLive && live.breakdown.length) {
|
|
492
|
+
details.push(buildDetailSection({
|
|
493
|
+
id: 'claude-week-by-product',
|
|
494
|
+
label: 'This week by product',
|
|
495
|
+
note: 'Share of this week’s usage per product, as claude.ai reports it. Cloud sessions count under Claude Code.',
|
|
496
|
+
rows: live.breakdown.map((row) => ({ label: row.name, value: `${Math.round(row.percent * 10) / 10}%` })),
|
|
359
497
|
}));
|
|
360
|
-
const modelSection = buildDetailSection({ id: 'claude-models', label: 'By model (session)', rows: modelRows });
|
|
361
|
-
if (modelSection) details.push(modelSection);
|
|
362
498
|
}
|
|
499
|
+
const prepaid = normalizeClaudePrepaidCredits(account?.prepaid);
|
|
500
|
+
if (prepaid && prepaid.amount !== 0) {
|
|
501
|
+
details.push(buildDetailSection({
|
|
502
|
+
id: 'claude-credits',
|
|
503
|
+
label: 'Credits',
|
|
504
|
+
rows: [{ label: 'Prepaid credits', value: formatClaudeMoney(prepaid.amount, prepaid.currency), hint: 'Balance of the organisation' }],
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
details.push(...buildClaudeSessionSections(usage?.session));
|
|
508
|
+
details.push(...behaviorSections(usage?.behaviors));
|
|
363
509
|
|
|
364
|
-
|
|
510
|
+
const notes = [];
|
|
511
|
+
if (hasLive) {
|
|
512
|
+
// The claude.ai usage page also lists limit-reset vouchers; no endpoint
|
|
513
|
+
// the CLI login reaches has a field for them.
|
|
514
|
+
notes.push({
|
|
515
|
+
id: 'claude-limit-resets',
|
|
516
|
+
text: 'Limit resets (full / 5-hour) are shown on claude.ai only.',
|
|
517
|
+
link: { label: 'Open the usage page', url: CLAUDE_USAGE_URL },
|
|
518
|
+
});
|
|
519
|
+
} else if (toTrimmedString(account?.error)) {
|
|
520
|
+
notes.push({
|
|
521
|
+
id: 'claude-live-unavailable',
|
|
522
|
+
text: `Live account usage could not be read, so this is the reading of the last Claude turn. ${toTrimmedString(account.error)}`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
365
525
|
|
|
366
526
|
const resolvedMeters = meters.filter(Boolean);
|
|
367
527
|
const planLimitsMissing = !resolvedMeters.length;
|
|
528
|
+
const subscriptionType = toTrimmedString(usage?.subscriptionType);
|
|
368
529
|
return buildProviderCard({
|
|
369
530
|
provider: CLAUDE_PROVIDER_ID,
|
|
370
531
|
label: CLAUDE_LABEL,
|
|
371
532
|
status: planLimitsMissing ? STATUS_PARTIAL : STATUS_OK,
|
|
372
|
-
planName:
|
|
373
|
-
? `${
|
|
533
|
+
planName: subscriptionType
|
|
534
|
+
? `${subscriptionType.charAt(0).toUpperCase()}${subscriptionType.slice(1)}`
|
|
374
535
|
: null,
|
|
375
536
|
message: planLimitsMissing
|
|
376
|
-
? (usage
|
|
537
|
+
? (hasLive || usage?.rateLimitsAvailable
|
|
377
538
|
? 'Plan rate limits were not reported for this session.'
|
|
378
539
|
: 'This session authenticates without claude.ai plan limits (API key or third-party provider), so only session cost is available.')
|
|
379
540
|
: null,
|
|
380
|
-
source: stale ? SOURCE_CACHE : SOURCE_WORKER,
|
|
381
|
-
stale,
|
|
382
|
-
capturedAt,
|
|
541
|
+
source: hasLive ? SOURCE_LIVE : (stale ? SOURCE_CACHE : SOURCE_WORKER),
|
|
542
|
+
stale: hasLive ? false : stale,
|
|
543
|
+
capturedAt: hasLive ? (toIsoTimestamp(account?.fetchedAt) || capturedAt) : capturedAt,
|
|
383
544
|
meters: resolvedMeters,
|
|
384
|
-
details,
|
|
545
|
+
details: details.filter(Boolean),
|
|
546
|
+
notes,
|
|
385
547
|
links: [{ label: 'Claude usage settings', url: CLAUDE_USAGE_URL }],
|
|
386
548
|
});
|
|
387
549
|
}
|