@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
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
4
|
+
CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
5
|
+
classifyClaudeResultFailure,
|
|
6
|
+
claudeFailureGuidance,
|
|
7
|
+
} from './claude-turn-failure.mjs';
|
|
3
8
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
4
9
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
5
10
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
6
11
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
7
12
|
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
13
|
+
import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
8
14
|
|
|
9
15
|
const PLAN_BOARD_ACTIONS = [
|
|
10
16
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -61,7 +67,60 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
|
|
|
61
67
|
* mutable turn state, so the session process can run any number of turns
|
|
62
68
|
* (delivered or background-continuation) through one publisher.
|
|
63
69
|
*/
|
|
64
|
-
export function createClaudeTurnPublisher({
|
|
70
|
+
export function createClaudeTurnPublisher({
|
|
71
|
+
api,
|
|
72
|
+
dbg = () => {},
|
|
73
|
+
takeWorkflowRuns = null,
|
|
74
|
+
// A backend's own reading of a failed request (`{ code, guidance }` or
|
|
75
|
+
// null) and its name in the notes; null for Claude. It gets the error a
|
|
76
|
+
// turn ended with, when there is one, beside its text and status.
|
|
77
|
+
describeFailure = null,
|
|
78
|
+
runtimeLabel = 'Claude',
|
|
79
|
+
// The namespace of a backend's failure codes (DeepSeek: `deepseek`): its
|
|
80
|
+
// failures print as `relay.deepseek-<code>`, which the browser's buttons
|
|
81
|
+
// key on (relay-error-ctas.mjs), not as Claude's `relay.claude-<code>`.
|
|
82
|
+
failureCodePrefix = null,
|
|
83
|
+
// False for a backend that is not Claude's: no Claude plan windows or cost.
|
|
84
|
+
reportsPlanUsage = true,
|
|
85
|
+
// A backend's own usage route (DeepSeek: /api/deepseek-plan-usage), which
|
|
86
|
+
// takes the CLI's token totals per model in place of the Claude plan
|
|
87
|
+
// usage, and the relay's id of a model the CLI names.
|
|
88
|
+
usageRoute = null,
|
|
89
|
+
usageModelKey = null,
|
|
90
|
+
// The relay's id of a model the backend's CLI names ("deepseek-flash[1m]"
|
|
91
|
+
// → "deepseek-flash"); identity for Claude, whose `[1m]` is a real tier.
|
|
92
|
+
relayModel = (value) => value,
|
|
93
|
+
// The Claude API booking (POST /api/claude-api-usage), which both Claude
|
|
94
|
+
// workers report to after every turn, whatever else they report: a chat
|
|
95
|
+
// moves between the subscription and the key and keeps its session.
|
|
96
|
+
// `billingKind` is the worker's kind.
|
|
97
|
+
billingUsageRoute = null,
|
|
98
|
+
billingKind = null,
|
|
99
|
+
} = {}) {
|
|
100
|
+
/** `{ code, stableCode }` of a failure code, in the backend's namespace. */
|
|
101
|
+
function failureCodes(code) {
|
|
102
|
+
if (!failureCodePrefix) return { code, stableCode: `claude.${code}` };
|
|
103
|
+
const prefixed = String(code).startsWith(`${failureCodePrefix}-`) ? code : `${failureCodePrefix}-${String(code).replace(/_/g, '-')}`;
|
|
104
|
+
return { code: prefixed, stableCode: prefixed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The Claude reading of a failed result, in the backend's namespace. A
|
|
109
|
+
* backend gets no Claude account or sign-in advice: the relay's default
|
|
110
|
+
* fits where the backend has none of its own.
|
|
111
|
+
*/
|
|
112
|
+
function classifyResultFailure(result) {
|
|
113
|
+
const classified = classifyClaudeResultFailure(result);
|
|
114
|
+
if (!failureCodePrefix) return classified;
|
|
115
|
+
return { ...failureCodes(classified.code), guidance: classified.code === 'model_not_found' ? classified.guidance : null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function backendFailure(errorText, status = null, error = null) {
|
|
119
|
+
if (typeof describeFailure !== 'function') return null;
|
|
120
|
+
const described = describeFailure(errorText, { status, ...(error ? { error } : {}) });
|
|
121
|
+
return described?.code ? described : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
65
124
|
function drainWorkflowRuns({ terminal = false } = {}) {
|
|
66
125
|
if (terminal || typeof takeWorkflowRuns !== 'function') return null;
|
|
67
126
|
return takeWorkflowRuns();
|
|
@@ -150,11 +209,14 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
150
209
|
*/
|
|
151
210
|
async function publishContextUsage({ message, state, model, sdkSessionId }) {
|
|
152
211
|
if (!state.contextUsage && !state.modelUsage) return;
|
|
212
|
+
const contextUsage = state.contextUsage && typeof state.contextUsage === 'object' && state.contextUsage.model
|
|
213
|
+
? { ...state.contextUsage, model: relayModel(state.contextUsage.model) }
|
|
214
|
+
: state.contextUsage;
|
|
153
215
|
await api('POST', '/api/claude-context-usage', {
|
|
154
216
|
conversationId: message.conversationId,
|
|
155
217
|
sdkSessionId,
|
|
156
|
-
model: state.responseModel || model || null,
|
|
157
|
-
contextUsage
|
|
218
|
+
model: relayModel(state.responseModel || model || null),
|
|
219
|
+
contextUsage,
|
|
158
220
|
modelUsage: state.modelUsage,
|
|
159
221
|
}).catch((error) => {
|
|
160
222
|
dbg('context usage publish failed', error?.message || String(error));
|
|
@@ -167,7 +229,13 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
167
229
|
* control request is unavailable, so the card still gets session cost.
|
|
168
230
|
* Advisory: a failure here must not disturb the turn's response.
|
|
169
231
|
*/
|
|
170
|
-
async function publishPlanUsage({ message, state, sdkSessionId }) {
|
|
232
|
+
async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
233
|
+
if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
234
|
+
if (usageRoute) {
|
|
235
|
+
await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!reportsPlanUsage) return;
|
|
171
239
|
const totalCostUsd = state.result?.totalCostUsd ?? null;
|
|
172
240
|
// A cost with no per-model breakdown is still a usable fallback payload, so
|
|
173
241
|
// it has to count as a reason to publish or the card loses session cost on
|
|
@@ -184,6 +252,283 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
184
252
|
});
|
|
185
253
|
}
|
|
186
254
|
|
|
255
|
+
// The CLI process's own token totals and web search and fetch counts per
|
|
256
|
+
// model, counted from zero for each process (`runId`) and CLI session id
|
|
257
|
+
// (a `/clear` changes it and starts the CLI's totals over). `lastSession`
|
|
258
|
+
// is the CLI's running totals per model at the last result they were taken
|
|
259
|
+
// from, `lastSearches` its running search counts the same way, `results`
|
|
260
|
+
// how many results the process has reported, and
|
|
261
|
+
// `countedStates` the turn states already added: a turn published twice
|
|
262
|
+
// (a stopped turn's chain and the stream's end) counts once.
|
|
263
|
+
const run = { key: '', runId: '', results: 0, totals: new Map(), requests: new Map(), lastSession: new Map(), lastSearches: new Map() };
|
|
264
|
+
const countedStates = new WeakSet();
|
|
265
|
+
|
|
266
|
+
const addTotals = (into, entries) => {
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
const current = into.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
269
|
+
current.hitTokens += entry.hitTokens;
|
|
270
|
+
current.missTokens += entry.missTokens;
|
|
271
|
+
current.outputTokens += entry.outputTokens;
|
|
272
|
+
into.set(entry.model, current);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The process's running totals and request counts after this turn, as
|
|
278
|
+
* `{ totals: [{ model, hitTokens, missTokens, outputTokens }], requests:
|
|
279
|
+
* [{ model, searchRequests, fetchRequests }] }` without the all-zero
|
|
280
|
+
* models. Without a run id (a caller that has none) the CLI's own totals
|
|
281
|
+
* and the turn's counts, as before.
|
|
282
|
+
*
|
|
283
|
+
* A result's `modelUsage` is the CLI session's running total per model
|
|
284
|
+
* (sub-agents included), so the turn's share is what it grew by since the
|
|
285
|
+
* process's last result. The first result of a process spawned to resume
|
|
286
|
+
* a session carries that session's earlier turns too, which the relay has
|
|
287
|
+
* already booked: that turn's share is the result's `usage`, the turn's
|
|
288
|
+
* own count, under the turn's model (it leaves out what sub-agents spent
|
|
289
|
+
* in that one turn). A process spawned fresh, or a session the CLI
|
|
290
|
+
* started over, reports from zero: its first result is all its own. A
|
|
291
|
+
* result without `modelUsage` adds its `usage`. The baseline the growth
|
|
292
|
+
* is measured from only rises, field by field per model: a result's
|
|
293
|
+
* totals below it belong to an older turn published late.
|
|
294
|
+
*
|
|
295
|
+
* Web searches come from the same place as the tokens: the CLI counts
|
|
296
|
+
* them per model in `modelUsage`, the WebSearch tool's own API request
|
|
297
|
+
* included, which the message stream (`serverToolUse`) never shows. They
|
|
298
|
+
* grow, rise and resume like the tokens; the stream's count stands in
|
|
299
|
+
* where the tokens fall back to `usage` (no `modelUsage`, the resumed
|
|
300
|
+
* first result) and for a CLI that reports no search count. Fetches only
|
|
301
|
+
* the stream counts.
|
|
302
|
+
*/
|
|
303
|
+
function runningCounts(state, result, { runId, sessionId, resumedSessionId, modelKey }) {
|
|
304
|
+
const model = result.model || state.responseModel;
|
|
305
|
+
const turnRequests = claudeToolRequests(result.serverToolUse, modelKey);
|
|
306
|
+
if (!runId) return { totals: claudeUsageTotals(result.modelUsage, { usage: result.usage, model, ...modelKey }), requests: turnRequests };
|
|
307
|
+
if (run.runId !== runId) {
|
|
308
|
+
run.runId = runId;
|
|
309
|
+
run.results = 0;
|
|
310
|
+
}
|
|
311
|
+
const key = `${runId}\n${sessionId}`;
|
|
312
|
+
if (run.key !== key) {
|
|
313
|
+
run.key = key;
|
|
314
|
+
run.totals = new Map();
|
|
315
|
+
run.requests = new Map();
|
|
316
|
+
run.lastSession = new Map();
|
|
317
|
+
run.lastSearches = new Map();
|
|
318
|
+
}
|
|
319
|
+
if (!countedStates.has(state)) {
|
|
320
|
+
countedStates.add(state);
|
|
321
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
322
|
+
const turnUsage = claudeUsageTotals(null, { usage: result.usage, model, ...modelKey });
|
|
323
|
+
const sessionSearches = hasModelUsage ? claudeSearchTotals(result.modelUsage, modelKey) : [];
|
|
324
|
+
// The searches this turn adds, per model: the stream's unless the
|
|
325
|
+
// CLI's running count below says more reliably.
|
|
326
|
+
let turnSearches = turnRequests.map((entry) => ({ model: entry.model, searchRequests: entry.searchRequests }));
|
|
327
|
+
if (!hasModelUsage) {
|
|
328
|
+
// The CLI's running totals grew by the turn, as far as this goes.
|
|
329
|
+
addTotals(run.totals, turnUsage);
|
|
330
|
+
addTotals(run.lastSession, turnUsage);
|
|
331
|
+
for (const entry of turnSearches) {
|
|
332
|
+
run.lastSearches.set(entry.model, (run.lastSearches.get(entry.model) || 0) + entry.searchRequests);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
const session = claudeUsageTotals(result.modelUsage, modelKey);
|
|
336
|
+
const resumed = run.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
337
|
+
if (resumed) {
|
|
338
|
+
addTotals(run.totals, turnUsage);
|
|
339
|
+
} else {
|
|
340
|
+
const grown = session.map((entry) => {
|
|
341
|
+
const last = run.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
342
|
+
return {
|
|
343
|
+
model: entry.model,
|
|
344
|
+
hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
|
|
345
|
+
missTokens: Math.max(0, entry.missTokens - last.missTokens),
|
|
346
|
+
outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
addTotals(run.totals, grown);
|
|
350
|
+
if (sessionSearches.length) {
|
|
351
|
+
turnSearches = sessionSearches.map((entry) => ({
|
|
352
|
+
model: entry.model,
|
|
353
|
+
searchRequests: Math.max(0, entry.searchRequests - (run.lastSearches.get(entry.model) || 0)),
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// The baseline only ever rises: the CLI's totals are cumulative, so a
|
|
358
|
+
// snapshot below the last one is an older result arriving late (a
|
|
359
|
+
// stopped turn's publish chain behind a newer turn's), and taking it
|
|
360
|
+
// as the baseline would book the newer turn's growth a second time.
|
|
361
|
+
for (const entry of session) {
|
|
362
|
+
const last = run.lastSession.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
363
|
+
run.lastSession.set(entry.model, {
|
|
364
|
+
model: entry.model,
|
|
365
|
+
hitTokens: Math.max(last.hitTokens, entry.hitTokens),
|
|
366
|
+
missTokens: Math.max(last.missTokens, entry.missTokens),
|
|
367
|
+
outputTokens: Math.max(last.outputTokens, entry.outputTokens),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
for (const entry of sessionSearches) {
|
|
371
|
+
run.lastSearches.set(entry.model, Math.max(run.lastSearches.get(entry.model) || 0, entry.searchRequests));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const addRequests = (entryModel, searchRequests, fetchRequests) => {
|
|
375
|
+
const current = run.requests.get(entryModel) || { model: entryModel, searchRequests: 0, fetchRequests: 0 };
|
|
376
|
+
current.searchRequests += searchRequests;
|
|
377
|
+
current.fetchRequests += fetchRequests;
|
|
378
|
+
run.requests.set(entryModel, current);
|
|
379
|
+
};
|
|
380
|
+
for (const entry of turnSearches) addRequests(entry.model, entry.searchRequests, 0);
|
|
381
|
+
for (const entry of turnRequests) addRequests(entry.model, 0, entry.fetchRequests);
|
|
382
|
+
run.results += 1;
|
|
383
|
+
}
|
|
384
|
+
// Copies: the running entries go on changing after the post.
|
|
385
|
+
return {
|
|
386
|
+
totals: [...run.totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0).map((entry) => ({ ...entry })),
|
|
387
|
+
requests: [...run.requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0).map((entry) => ({ ...entry })),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Ship the CLI process's token totals per model after a turn, with the
|
|
393
|
+
* CLI's session id, the id of the process (`runId`) and the time. They are
|
|
394
|
+
* the process's own running totals, counted from zero (runningCounts): the
|
|
395
|
+
* relay books what each report adds under the run id, a report below the
|
|
396
|
+
* last one under it is stale, and a new run id is booked in full. The
|
|
397
|
+
* process's running counts of web searches and fetches ride along as
|
|
398
|
+
* `requests`, per model, booked the same way. A caller without a run id
|
|
399
|
+
* sends the CLI's session totals and the turn's counts, which the relay
|
|
400
|
+
* books as an older worker's. `resumedSessionId` is the session the process
|
|
401
|
+
* was spawned to resume, if any. Advisory, like the plan usage.
|
|
402
|
+
*/
|
|
403
|
+
async function publishBackendUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
404
|
+
const result = state.result;
|
|
405
|
+
const claudeNativeSessionId = String(result?.sessionId || '').trim();
|
|
406
|
+
if (!usageRoute || !claudeNativeSessionId) return;
|
|
407
|
+
const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
|
|
408
|
+
const { totals, requests } = runningCounts(state, result, {
|
|
409
|
+
runId: String(runId || ''),
|
|
410
|
+
sessionId: claudeNativeSessionId,
|
|
411
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
412
|
+
modelKey,
|
|
413
|
+
});
|
|
414
|
+
if (!totals.length && !requests.length) return;
|
|
415
|
+
await api('POST', usageRoute, {
|
|
416
|
+
conversationId: message.conversationId,
|
|
417
|
+
sdkSessionId,
|
|
418
|
+
claudeNativeSessionId,
|
|
419
|
+
...(runId ? { runId } : {}),
|
|
420
|
+
at: new Date().toISOString(),
|
|
421
|
+
totals,
|
|
422
|
+
...(requests.length ? { requests } : {}),
|
|
423
|
+
}).catch((error) => {
|
|
424
|
+
dbg('backend usage publish failed', error?.message || String(error));
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The process's own totals per model with their cost, for the Claude API
|
|
429
|
+
// booking: counted like `run` above, per process and CLI session, with the
|
|
430
|
+
// CLI's cost estimate beside the tokens. A resumed process's first result
|
|
431
|
+
// adds the turn's `usage` (its main agent loop, no cost); the relay books
|
|
432
|
+
// that turn from the session totals the report carries as well.
|
|
433
|
+
const billingRun = { key: '', results: 0, totals: new Map(), lastSession: new Map() };
|
|
434
|
+
const billedStates = new WeakSet();
|
|
435
|
+
|
|
436
|
+
function billingCounts(state, result, { runId, sessionId, resumedSessionId }) {
|
|
437
|
+
const model = result.model || state.responseModel;
|
|
438
|
+
const key = `${runId}\n${sessionId}`;
|
|
439
|
+
if (billingRun.key !== key) {
|
|
440
|
+
billingRun.key = key;
|
|
441
|
+
billingRun.results = 0;
|
|
442
|
+
billingRun.totals = new Map();
|
|
443
|
+
billingRun.lastSession = new Map();
|
|
444
|
+
}
|
|
445
|
+
const firstResultOfRun = billingRun.results === 0;
|
|
446
|
+
const session = claudeBillingTotals(result.modelUsage, { usage: result.usage, model });
|
|
447
|
+
const add = (entries) => {
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
const current = billingRun.totals.get(entry.model) || { model: entry.model, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
|
|
450
|
+
current.hitTokens += entry.hitTokens;
|
|
451
|
+
current.missTokens += entry.missTokens;
|
|
452
|
+
current.outputTokens += entry.outputTokens;
|
|
453
|
+
current.costUsd += entry.costUsd;
|
|
454
|
+
current.costUnknown = current.costUnknown || entry.costUnknown;
|
|
455
|
+
billingRun.totals.set(entry.model, current);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
if (!billedStates.has(state)) {
|
|
459
|
+
billedStates.add(state);
|
|
460
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
461
|
+
const resumed = firstResultOfRun && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
462
|
+
if (!hasModelUsage || resumed) {
|
|
463
|
+
add(claudeBillingTotals(null, { usage: result.usage, model }));
|
|
464
|
+
} else {
|
|
465
|
+
add(session.map((entry) => {
|
|
466
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
467
|
+
return {
|
|
468
|
+
model: entry.model,
|
|
469
|
+
hitTokens: Math.max(0, entry.hitTokens - last.hitTokens),
|
|
470
|
+
missTokens: Math.max(0, entry.missTokens - last.missTokens),
|
|
471
|
+
outputTokens: Math.max(0, entry.outputTokens - last.outputTokens),
|
|
472
|
+
costUsd: Math.max(0, entry.costUsd - last.costUsd),
|
|
473
|
+
costUnknown: entry.costUnknown,
|
|
474
|
+
};
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
// The baseline only rises: a late result of an older turn below it
|
|
478
|
+
// must not make the next one count twice.
|
|
479
|
+
if (hasModelUsage) {
|
|
480
|
+
for (const entry of session) {
|
|
481
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
482
|
+
billingRun.lastSession.set(entry.model, {
|
|
483
|
+
hitTokens: Math.max(last.hitTokens, entry.hitTokens),
|
|
484
|
+
missTokens: Math.max(last.missTokens, entry.missTokens),
|
|
485
|
+
outputTokens: Math.max(last.outputTokens, entry.outputTokens),
|
|
486
|
+
costUsd: Math.max(last.costUsd, entry.costUsd),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
billingRun.results += 1;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
firstResultOfRun,
|
|
494
|
+
runCounts: [...billingRun.totals.values()].map((entry) => ({ ...entry })),
|
|
495
|
+
sessionTotals: session,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Ship the turn's counts to the Claude API booking: the process's own
|
|
501
|
+
* running totals (`runCounts`) and the CLI's session totals
|
|
502
|
+
* (`sessionTotals`), with the worker's kind, the process (`runId`), the
|
|
503
|
+
* session it resumed, and the turn (`turnId`). Advisory, like the plan usage.
|
|
504
|
+
*/
|
|
505
|
+
async function publishBillingUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
506
|
+
const result = state.result;
|
|
507
|
+
const claudeNativeSessionId = String(result?.sessionId || '').trim();
|
|
508
|
+
if (!billingUsageRoute || !claudeNativeSessionId || !runId) return;
|
|
509
|
+
const { firstResultOfRun, runCounts, sessionTotals } = billingCounts(state, result, {
|
|
510
|
+
runId: String(runId),
|
|
511
|
+
sessionId: claudeNativeSessionId,
|
|
512
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
513
|
+
});
|
|
514
|
+
if (!runCounts.length && !sessionTotals.length) return;
|
|
515
|
+
await api('POST', billingUsageRoute, {
|
|
516
|
+
conversationId: message.conversationId,
|
|
517
|
+
sdkSessionId,
|
|
518
|
+
claudeNativeSessionId,
|
|
519
|
+
runId: String(runId),
|
|
520
|
+
billing: billingKind || null,
|
|
521
|
+
turnId: String(message.id || '') || null,
|
|
522
|
+
at: new Date().toISOString(),
|
|
523
|
+
firstResultOfRun,
|
|
524
|
+
...(resumedSessionId ? { resumedSessionId: String(resumedSessionId) } : {}),
|
|
525
|
+
runCounts,
|
|
526
|
+
sessionTotals,
|
|
527
|
+
}).catch((error) => {
|
|
528
|
+
dbg('billing usage publish failed', error?.message || String(error));
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
187
532
|
/**
|
|
188
533
|
* Ship what the CLI reported about the subscription's usage limit: the
|
|
189
534
|
* relay warns before the limit and shows the reset once it is reached.
|
|
@@ -353,7 +698,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
353
698
|
async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
|
|
354
699
|
const result = state.result;
|
|
355
700
|
const errorText = result.text
|
|
356
|
-
||
|
|
701
|
+
|| `${runtimeLabel} turn failed (${result.subtype || 'unknown error'}).`;
|
|
702
|
+
const described = backendFailure(errorText, result.apiErrorStatus);
|
|
357
703
|
await publishFinalStream(message, state.lastStreamedText);
|
|
358
704
|
// Refused at the usage limit: a relay that knows the kind pauses the turn
|
|
359
705
|
// until the reset instead of failing it.
|
|
@@ -382,7 +728,8 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
382
728
|
// Never built from the subtype alone: a refused turn ends with
|
|
383
729
|
// subtype 'success'. An account failure brings its own advice, since
|
|
384
730
|
// sending the message again cannot help before the account is fixed.
|
|
385
|
-
...
|
|
731
|
+
...classifyResultFailure(result),
|
|
732
|
+
...(described ? { ...failureCodes(described.code), guidance: described.guidance } : {}),
|
|
386
733
|
message: errorText,
|
|
387
734
|
failedAt: new Date().toISOString(),
|
|
388
735
|
queueMessageId: String(message.id || '') || null,
|
|
@@ -400,6 +747,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
400
747
|
});
|
|
401
748
|
return;
|
|
402
749
|
}
|
|
750
|
+
// Claude Code did not take a backend's connection settings (its key and
|
|
751
|
+
// endpoint pins): nothing was sent, and no sign-in changes that.
|
|
752
|
+
if (error?.code === CLAUDE_LIVE_FLAG_SETTINGS_CODE) {
|
|
753
|
+
await publishResponse(message, {
|
|
754
|
+
text: `System note: ${error.message} ${CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE}`,
|
|
755
|
+
model: null,
|
|
756
|
+
terminalError: {
|
|
757
|
+
kind: 'claude-turn-failed',
|
|
758
|
+
code: CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
759
|
+
stableCode: `claude.${CLAUDE_LIVE_FLAG_SETTINGS_CODE}`,
|
|
760
|
+
message: error.message,
|
|
761
|
+
guidance: CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
762
|
+
failedAt: new Date().toISOString(),
|
|
763
|
+
queueMessageId: String(message.id || '') || null,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
403
768
|
// The conversation's folder is gone: the error names it and says what to
|
|
404
769
|
// do, which neither a retry nor a sign-in changes.
|
|
405
770
|
if (isWorkingDirectoryMissingError(error)) {
|
|
@@ -408,8 +773,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
408
773
|
model: null,
|
|
409
774
|
terminalError: {
|
|
410
775
|
kind: 'claude-turn-failed',
|
|
411
|
-
|
|
412
|
-
stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
|
|
776
|
+
...failureCodes(WORKING_DIRECTORY_MISSING_CODE),
|
|
413
777
|
message: error.message,
|
|
414
778
|
guidance: error.guidance,
|
|
415
779
|
failedAt: new Date().toISOString(),
|
|
@@ -418,16 +782,33 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
418
782
|
});
|
|
419
783
|
return;
|
|
420
784
|
}
|
|
421
|
-
const
|
|
785
|
+
const described = backendFailure(errorText, null, error);
|
|
786
|
+
if (described) {
|
|
787
|
+
await publishResponse(message, {
|
|
788
|
+
text: `System note: the ${runtimeLabel} turn failed (${errorText}). ${described.guidance}`,
|
|
789
|
+
model: null,
|
|
790
|
+
terminalError: {
|
|
791
|
+
kind: 'claude-turn-failed',
|
|
792
|
+
...failureCodes(described.code),
|
|
793
|
+
message: errorText,
|
|
794
|
+
guidance: described.guidance,
|
|
795
|
+
failedAt: new Date().toISOString(),
|
|
796
|
+
queueMessageId: String(message.id || '') || null,
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// A backend's sign-in failures are its describeFailure's; Claude's sign-in
|
|
802
|
+
// advice is never a backend's.
|
|
803
|
+
const isAuthError = !failureCodePrefix && /authentication|logged in|login|credential|api key/i.test(errorText);
|
|
422
804
|
await publishResponse(message, {
|
|
423
805
|
text: isAuthError
|
|
424
806
|
? `System note: the Claude runtime could not authenticate (${errorText}). Log in again from Settings → Providers → Claude → Relogin (or run \`claude\` on the relay host), then retry.`
|
|
425
|
-
: `System note: the
|
|
807
|
+
: `System note: the ${runtimeLabel} turn failed (${errorText}). Retry or send a new message.`,
|
|
426
808
|
model: null,
|
|
427
809
|
terminalError: {
|
|
428
810
|
kind: 'claude-turn-failed',
|
|
429
|
-
|
|
430
|
-
stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
|
|
811
|
+
...failureCodes(isAuthError ? 'authentication_failed' : 'turn-error'),
|
|
431
812
|
message: errorText,
|
|
432
813
|
...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
|
|
433
814
|
failedAt: new Date().toISOString(),
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The CLI's token totals per model, out of a result message.
|
|
2
|
+
//
|
|
3
|
+
// A result's `modelUsage` is the CLI session's running total, not the turn's:
|
|
4
|
+
// a long-lived CLI reports more on every turn, a CLI resumed on a session
|
|
5
|
+
// starts from the totals that session's transcript saved (its first result
|
|
6
|
+
// already carries the earlier turns), and a `/clear` starts it over. Its
|
|
7
|
+
// `usage` is the turn's own, and the main agent loop's only (sub-agents and
|
|
8
|
+
// auxiliary calls are left out). The publisher turns these into the
|
|
9
|
+
// process's own running totals (claude-turn-publisher.mjs, runningCounts),
|
|
10
|
+
// which the relay books per process (plan-usage-service.mjs,
|
|
11
|
+
// recordDeepSeekUsageReport).
|
|
12
|
+
|
|
13
|
+
function count(value) {
|
|
14
|
+
const number = Number(value ?? 0);
|
|
15
|
+
return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function addTo(totals, model, hit, miss, output) {
|
|
19
|
+
const current = totals.get(model) || { model, hitTokens: 0, missTokens: 0, outputTokens: 0 };
|
|
20
|
+
current.hitTokens += hit;
|
|
21
|
+
current.missTokens += miss;
|
|
22
|
+
current.outputTokens += output;
|
|
23
|
+
totals.set(model, current);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `[{ model, hitTokens, missTokens, outputTokens }]`: cache reads are hits,
|
|
28
|
+
* fresh and cache-creation input are misses. `modelKey` maps the CLI's name
|
|
29
|
+
* of a model to the relay's (DeepSeek: without the `[1m]` suffix); entries
|
|
30
|
+
* that map to the same id are summed. `usage` stands in only for a result
|
|
31
|
+
* without `modelUsage`, under the turn's model (pass `modelUsage` as null to
|
|
32
|
+
* read `usage` alone). Models with no tokens are left out.
|
|
33
|
+
*/
|
|
34
|
+
export function claudeUsageTotals(modelUsage, {
|
|
35
|
+
usage = null,
|
|
36
|
+
model = '',
|
|
37
|
+
modelKey = (value) => String(value ?? '').trim(),
|
|
38
|
+
} = {}) {
|
|
39
|
+
const keyOf = (value) => String(modelKey(value) ?? '').trim().toLowerCase();
|
|
40
|
+
const totals = new Map();
|
|
41
|
+
if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
|
|
42
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
43
|
+
const key = keyOf(name);
|
|
44
|
+
if (!key || !entry || typeof entry !== 'object') continue;
|
|
45
|
+
addTo(
|
|
46
|
+
totals,
|
|
47
|
+
key,
|
|
48
|
+
count(entry.cacheReadInputTokens),
|
|
49
|
+
count(entry.inputTokens) + count(entry.cacheCreationInputTokens),
|
|
50
|
+
count(entry.outputTokens),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const key = keyOf(model);
|
|
55
|
+
if (key && usage && typeof usage === 'object') {
|
|
56
|
+
addTo(
|
|
57
|
+
totals,
|
|
58
|
+
key,
|
|
59
|
+
count(usage.cache_read_input_tokens),
|
|
60
|
+
count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
|
|
61
|
+
count(usage.output_tokens),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The CLI's running count of web searches per model, `[{ model,
|
|
70
|
+
* searchRequests }]`, out of a result's `modelUsage`: the CLI adds every API
|
|
71
|
+
* request's `server_tool_use.web_search_requests` to it, the internal request
|
|
72
|
+
* its WebSearch tool makes included, which never shows up in the message
|
|
73
|
+
* stream. Same `modelKey` and summing as the token totals. Only the entries
|
|
74
|
+
* that carry the count, zero included, so an empty list means the CLI does
|
|
75
|
+
* not report it. It has no fetch count.
|
|
76
|
+
*/
|
|
77
|
+
export function claudeSearchTotals(modelUsage, { modelKey = (value) => String(value ?? '').trim() } = {}) {
|
|
78
|
+
const searches = new Map();
|
|
79
|
+
if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
|
|
80
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
81
|
+
const key = String(modelKey(name) ?? '').trim().toLowerCase();
|
|
82
|
+
if (!key || !entry || typeof entry !== 'object' || !Number.isFinite(Number(entry.webSearchRequests ?? NaN))) continue;
|
|
83
|
+
const current = searches.get(key) || { model: key, searchRequests: 0 };
|
|
84
|
+
current.searchRequests += count(entry.webSearchRequests);
|
|
85
|
+
searches.set(key, current);
|
|
86
|
+
}
|
|
87
|
+
return [...searches.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The turn's web searches and fetches per model, `[{ model, searchRequests,
|
|
92
|
+
* fetchRequests }]`, from the result's `serverToolUse` (the normalizer's
|
|
93
|
+
* per-turn count, not a running total): the message stream's, which leaves
|
|
94
|
+
* out the WebSearch tool's own request. Same `modelKey` and summing as the
|
|
95
|
+
* token totals; models with no requests are left out.
|
|
96
|
+
*/
|
|
97
|
+
export function claudeToolRequests(serverToolUse, { modelKey = (value) => String(value ?? '').trim() } = {}) {
|
|
98
|
+
const requests = new Map();
|
|
99
|
+
for (const entry of Array.isArray(serverToolUse) ? serverToolUse : []) {
|
|
100
|
+
const key = String(modelKey(entry?.model) ?? '').trim().toLowerCase();
|
|
101
|
+
if (!key || !entry || typeof entry !== 'object') continue;
|
|
102
|
+
const current = requests.get(key) || { model: key, searchRequests: 0, fetchRequests: 0 };
|
|
103
|
+
current.searchRequests += count(entry.webSearchRequests);
|
|
104
|
+
current.fetchRequests += count(entry.webFetchRequests);
|
|
105
|
+
requests.set(key, current);
|
|
106
|
+
}
|
|
107
|
+
return [...requests.values()].filter((entry) => entry.searchRequests + entry.fetchRequests > 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function costOf(value) {
|
|
111
|
+
const number = Number(value ?? 0);
|
|
112
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The CLI's totals per model with Claude Code's own cost estimate, for the
|
|
117
|
+
* Claude API booking (claude-api-usage-service.mjs): `[{ model, hitTokens,
|
|
118
|
+
* missTokens, outputTokens, costUsd, costUnknown }]`. From `modelUsage` (the
|
|
119
|
+
* session's running totals, sub-agents and helper calls included; `costUSD`
|
|
120
|
+
* per model, `costBasis: 'unknown'` for a model the CLI has no price for), or
|
|
121
|
+
* from `usage` alone under the turn's model (pass `modelUsage` as null): the
|
|
122
|
+
* turn's main agent loop, which carries no cost. Model ids keep Claude's
|
|
123
|
+
* `[1m]` tier; the relay books them under the base id.
|
|
124
|
+
*/
|
|
125
|
+
export function claudeBillingTotals(modelUsage, { usage = null, model = '' } = {}) {
|
|
126
|
+
const totals = new Map();
|
|
127
|
+
const add = (name, hit, miss, output, costUsd, costUnknown) => {
|
|
128
|
+
const key = String(name ?? '').trim().toLowerCase();
|
|
129
|
+
if (!key) return;
|
|
130
|
+
const current = totals.get(key) || { model: key, hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0, costUnknown: false };
|
|
131
|
+
current.hitTokens += hit;
|
|
132
|
+
current.missTokens += miss;
|
|
133
|
+
current.outputTokens += output;
|
|
134
|
+
current.costUsd += costUsd;
|
|
135
|
+
current.costUnknown = current.costUnknown || costUnknown;
|
|
136
|
+
totals.set(key, current);
|
|
137
|
+
};
|
|
138
|
+
if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
|
|
139
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
140
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
141
|
+
add(
|
|
142
|
+
name,
|
|
143
|
+
count(entry.cacheReadInputTokens),
|
|
144
|
+
count(entry.inputTokens) + count(entry.cacheCreationInputTokens),
|
|
145
|
+
count(entry.outputTokens),
|
|
146
|
+
costOf(entry.costUSD),
|
|
147
|
+
entry.costBasis === 'unknown',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} else if (usage && typeof usage === 'object') {
|
|
151
|
+
add(
|
|
152
|
+
model,
|
|
153
|
+
count(usage.cache_read_input_tokens),
|
|
154
|
+
count(usage.input_tokens) + count(usage.cache_creation_input_tokens),
|
|
155
|
+
count(usage.output_tokens),
|
|
156
|
+
0,
|
|
157
|
+
false,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0 || entry.costUsd > 0);
|
|
161
|
+
}
|