@oar-sh/oar 0.9.17 → 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/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +192 -0
- package/README.md +13 -12
- package/package.json +1 -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-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 +35 -9
- package/server/claude-worker/claude-session-process.mjs +166 -21
- package/server/claude-worker/claude-session-worker-main.mjs +3 -0
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-publisher.mjs +117 -5
- package/server/claude-worker/claude-usage-totals.mjs +53 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
- 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 +33 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
- 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/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 +52 -0
- package/server/public/app/bootstrap.js +153 -27
- 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 +6 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +11 -0
- package/server/public/app/conversation-view.js +29 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +127 -10
- package/server/public/app/model-selector-options.mjs +17 -1
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/provider-readiness.mjs +5 -2
- package/server/public/app/relay-error-ctas.mjs +50 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -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 +189 -3
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +1 -1
- 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 +1 -1
- package/server/routes/messages-routes.mjs +645 -27
- 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 +327 -29
- package/server/server-runtime.mjs +363 -26
- package/server/services/about-service.mjs +1 -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/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-service.mjs +5 -0
- package/server/services/provider-model-selection.mjs +3 -2
- package/server/services/provider-reasoning-effort.mjs +9 -0
- 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 +83 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +61 -2
- 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 +2 -0
- package/shared/anthropic-api.mjs +202 -0
- 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 +1 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/heartbeat.mjs +5 -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
|
+
}
|
|
@@ -862,6 +862,9 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
862
862
|
// (deepseek-usage-import.mjs readActuals()), or null: read only, laid
|
|
863
863
|
// over the estimate by the card.
|
|
864
864
|
deepseekActuals = null,
|
|
865
|
+
// The Claude API card, built by its own module (plan-usage-claude-api.mjs)
|
|
866
|
+
// from the Claude API booking; null while Claude API has no key and is off.
|
|
867
|
+
claudeApiCard = null,
|
|
865
868
|
} = {}) {
|
|
866
869
|
const generatedAt = nowIso();
|
|
867
870
|
const claudeSnapshot = readSnapshot('claude');
|
|
@@ -911,6 +914,8 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
911
914
|
configured: true,
|
|
912
915
|
})
|
|
913
916
|
: null,
|
|
917
|
+
// Opt-in too, and next to the Claude cards: the same models on a key.
|
|
918
|
+
claudeApiCard || null,
|
|
914
919
|
buildCursorPlanCard({
|
|
915
920
|
totals: cursorCycle.totals,
|
|
916
921
|
allowances,
|
|
@@ -90,7 +90,7 @@ export function resolveProviderModelSelection({
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// ─── Checking a model an agent names (
|
|
93
|
+
// ─── Checking a model an agent names (relay_tool create_session / send) ───
|
|
94
94
|
|
|
95
95
|
const PROVIDER_LABELS = Object.freeze({
|
|
96
96
|
github: 'Copilot',
|
|
@@ -100,8 +100,9 @@ const PROVIDER_LABELS = Object.freeze({
|
|
|
100
100
|
grok: 'Grok',
|
|
101
101
|
'claude-cloud': 'Claude Cloud',
|
|
102
102
|
deepseek: 'DeepSeek',
|
|
103
|
+
'claude-api': 'Claude API',
|
|
103
104
|
});
|
|
104
|
-
const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud']);
|
|
105
|
+
const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud', 'claude-api']);
|
|
105
106
|
const LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
|
|
106
107
|
// A Copilot model with its effort, as the composer sends it ("gpt-5.4-high").
|
|
107
108
|
const EFFORT_VARIANT_SUFFIX = /-(none|minimal|low|medium|high|xhigh|max)$/i;
|
|
@@ -45,6 +45,7 @@ export function supportedReasoningEffortsForProviderModel({
|
|
|
45
45
|
model = '',
|
|
46
46
|
cursorSettings = null,
|
|
47
47
|
claudeSettings = null,
|
|
48
|
+
claudeApiSettings = null,
|
|
48
49
|
grokSettings = null,
|
|
49
50
|
reasoningByModel = null,
|
|
50
51
|
} = {}) {
|
|
@@ -53,6 +54,14 @@ export function supportedReasoningEffortsForProviderModel({
|
|
|
53
54
|
if (provider === 'claude') {
|
|
54
55
|
return effortsForModel(claudeSettings?.effortsByModel, model) || [...DEFAULT_CLAUDE_REASONING_EFFORTS];
|
|
55
56
|
}
|
|
57
|
+
// Claude API runs the same CLI as Claude: its levels are the Claude
|
|
58
|
+
// provider's (Ultracode included), narrowed by what Anthropic's model list
|
|
59
|
+
// says about the model (claude-api-settings-service.mjs).
|
|
60
|
+
if (provider === 'claude-api') {
|
|
61
|
+
return effortsForModel(claudeApiSettings?.effortsByModel, model)
|
|
62
|
+
|| effortsForModel(claudeSettings?.effortsByModel, model)
|
|
63
|
+
|| [...DEFAULT_CLAUDE_REASONING_EFFORTS];
|
|
64
|
+
}
|
|
56
65
|
if (provider === 'grok') {
|
|
57
66
|
return effortsForModel(grokSettings?.effortsByModel, model) || [...DEFAULT_GROK_REASONING_EFFORTS];
|
|
58
67
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Questions that don't block:
|
|
4
|
+
* Questions that don't block: relay_tool's `ask` (shared/relay-asks.mjs).
|
|
5
5
|
* An agent asks the owner something and goes on working; the question shows
|
|
6
6
|
* as a card in its session and in the Inbox, and when the owner answers or
|
|
7
7
|
* dismisses it the relay tells the session the ask is in and the
|
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
relayEventQuietWindowEnd,
|
|
108
108
|
relayEventTypesTake,
|
|
109
109
|
} from '../../shared/relay-events.mjs';
|
|
110
|
+
import { REMOTE_RELAY_APPROVAL_SOURCE } from '../../shared/relay-asks.mjs';
|
|
110
111
|
|
|
111
112
|
export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
112
113
|
export const RELAY_EVENT_SELF_RELAY_ID = 'self';
|
|
@@ -2307,8 +2308,8 @@ export function createRelayEventService({
|
|
|
2307
2308
|
prompt: String(question.prompt || question.question || '').slice(0, 2000),
|
|
2308
2309
|
options,
|
|
2309
2310
|
questionId: toText(question.id),
|
|
2310
|
-
...(toText(context?.source) ===
|
|
2311
|
-
// A question that does not block (
|
|
2311
|
+
...(toText(context?.source) === REMOTE_RELAY_APPROVAL_SOURCE ? { approvalCard: true } : {}),
|
|
2312
|
+
// A question that does not block (relay_tool ask).
|
|
2312
2313
|
...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
|
|
2313
2314
|
});
|
|
2314
2315
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Relay schedules: a prompt the relay queues into a session at a set time,
|
|
5
|
-
* once or every N minutes (
|
|
5
|
+
* once or every N minutes (relay_tool's `schedule`). An agent's own timers
|
|
6
6
|
* die with the relay; these live in the database (migrations/0022-relay-schedules.mjs)
|
|
7
7
|
* and their timers are rebuilt at boot.
|
|
8
8
|
*
|
|
@@ -118,6 +118,13 @@ function describeRemoteTurn(action, args, caller, group = null) {
|
|
|
118
118
|
const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
|
|
119
119
|
parts.push(`Provider: ${provider}`);
|
|
120
120
|
}
|
|
121
|
+
// A send that names a Claude billing moves the session to it with the
|
|
122
|
+
// message: who pays for its turns changes, so the card says so.
|
|
123
|
+
if (action === 'send') {
|
|
124
|
+
const billing = toText(args.provider).toLowerCase();
|
|
125
|
+
if (billing === 'claude-api') parts.push('Switches billing to API key');
|
|
126
|
+
else if (billing === 'claude') parts.push('Switches billing to subscription');
|
|
127
|
+
}
|
|
121
128
|
if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
|
|
122
129
|
if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)}`);
|
|
123
130
|
if (action === 'create_session') {
|