@oar-sh/oar 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The DeepSeek platform's usage export, imported so the usage card in Check
|
|
5
|
+
* Usage shows what DeepSeek charged instead of the relay's estimate.
|
|
6
|
+
*
|
|
7
|
+
* The platform's usage page exports two CSV files for a date range:
|
|
8
|
+
* - the amounts: one row per period, model, API key and type
|
|
9
|
+
* (`input_cache_hit_tokens`, `input_cache_miss_tokens`, `output_tokens`,
|
|
10
|
+
* `request_count`) with the price per unit and the amount;
|
|
11
|
+
* - the costs: one row per period, model and wallet with the cost and its
|
|
12
|
+
* currency, for the whole account.
|
|
13
|
+
*
|
|
14
|
+
* The import keeps one row per export day, model and key in its own table
|
|
15
|
+
* (migrations/0030-deepseek-usage-actuals.mjs) with the key masked as
|
|
16
|
+
* `sk-XXXXX…YYYY`; the day is the date of the period's start in the export's
|
|
17
|
+
* time zone. Its cost is amount × price summed; before anything is written
|
|
18
|
+
* the sum over all keys of each period and model has to match the cost file,
|
|
19
|
+
* or the import is refused whole. A day with a period that has not ended yet
|
|
20
|
+
* (today's, in an export taken during the day) is left out: it would only be
|
|
21
|
+
* part of the day. Importing the same files again changes nothing; a later
|
|
22
|
+
* export replaces each key's rows of the days it covers, also when it was
|
|
23
|
+
* taken in another time zone.
|
|
24
|
+
*
|
|
25
|
+
* The card counts only the rows of the key saved in Settings → Providers →
|
|
26
|
+
* DeepSeek (`readActuals`), so the other keys of the account stay out of it.
|
|
27
|
+
* The relay's own usage tables are never touched.
|
|
28
|
+
*
|
|
29
|
+
* CLI: npm run deepseek:import -- amount.csv cost.csv [path/to/copilot.db]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { fileURLToPath } from 'url';
|
|
33
|
+
import fs from 'fs';
|
|
34
|
+
import path from 'path';
|
|
35
|
+
import process from 'process';
|
|
36
|
+
import Database from 'better-sqlite3';
|
|
37
|
+
|
|
38
|
+
import { DEEPSEEK_API_KEY_SETTING_KEY } from './deepseek-settings-service.mjs';
|
|
39
|
+
import { DEEPSEEK_USAGE_ACTUALS_TABLE, migrateDeepSeekUsageActuals } from '../migrations/0030-deepseek-usage-actuals.mjs';
|
|
40
|
+
|
|
41
|
+
// Per period and model, the amounts' cost and the cost file may differ by
|
|
42
|
+
// this much (the prices have up to eight decimals).
|
|
43
|
+
export const DEEPSEEK_IMPORT_COST_TOLERANCE = 1e-6;
|
|
44
|
+
|
|
45
|
+
const AMOUNT_HEADERS = Object.freeze(['start_time_iso', 'end_time_iso', 'model', 'api_key_name', 'api_key', 'type', 'price', 'amount']);
|
|
46
|
+
const COST_HEADERS = Object.freeze(['start_time_iso', 'end_time_iso', 'model', 'cost', 'currency']);
|
|
47
|
+
|
|
48
|
+
// The amount types that are token or request counts, and their columns.
|
|
49
|
+
const TYPE_FIELDS = Object.freeze({
|
|
50
|
+
input_cache_hit_tokens: 'hitTokens',
|
|
51
|
+
input_cache_miss_tokens: 'missTokens',
|
|
52
|
+
output_tokens: 'outputTokens',
|
|
53
|
+
request_count: 'requestCount',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const MODEL_PATTERN = /^[a-z0-9][a-z0-9._:/-]{0,127}$/;
|
|
57
|
+
const ISO_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-](\d{2}):(\d{2}))$/;
|
|
58
|
+
// A plain decimal: no sign, exponent, hex or blanks.
|
|
59
|
+
const DECIMAL = /^\d+(?:\.\d+)?$/;
|
|
60
|
+
|
|
61
|
+
const tenDecimals = (value) => Math.round(Number(value) * 1e10) / 1e10;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A key as `sk-XXXXX…YYYY`: the five characters after `sk-` and the last
|
|
65
|
+
* four, the same for the export's masked key and the saved one. '' for
|
|
66
|
+
* anything that is not a DeepSeek key.
|
|
67
|
+
*/
|
|
68
|
+
export function deepseekKeyFingerprint(value) {
|
|
69
|
+
const key = String(value ?? '').trim();
|
|
70
|
+
if (!/^sk-[A-Za-z0-9]{5}/.test(key) || key.length < 12) return '';
|
|
71
|
+
const tail = key.slice(-4);
|
|
72
|
+
if (!/^[A-Za-z0-9]{4}$/.test(tail)) return '';
|
|
73
|
+
return `${key.slice(0, 8)}…${tail}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The rows of a CSV text: a BOM and CRLF line ends are fine, quoted fields too. */
|
|
77
|
+
export function parseCsv(text) {
|
|
78
|
+
const source = String(text ?? '').replace(/^/, '');
|
|
79
|
+
const rows = [];
|
|
80
|
+
let row = [];
|
|
81
|
+
let field = '';
|
|
82
|
+
let quoted = false;
|
|
83
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
84
|
+
const char = source[index];
|
|
85
|
+
if (quoted) {
|
|
86
|
+
if (char === '"') {
|
|
87
|
+
if (source[index + 1] === '"') {
|
|
88
|
+
field += '"';
|
|
89
|
+
index += 1;
|
|
90
|
+
} else {
|
|
91
|
+
quoted = false;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
field += char;
|
|
95
|
+
}
|
|
96
|
+
} else if (char === '"' && field === '') {
|
|
97
|
+
quoted = true;
|
|
98
|
+
} else if (char === ',') {
|
|
99
|
+
row.push(field);
|
|
100
|
+
field = '';
|
|
101
|
+
} else if (char === '\n' || char === '\r') {
|
|
102
|
+
if (char === '\r' && source[index + 1] === '\n') index += 1;
|
|
103
|
+
row.push(field);
|
|
104
|
+
rows.push(row);
|
|
105
|
+
row = [];
|
|
106
|
+
field = '';
|
|
107
|
+
} else {
|
|
108
|
+
field += char;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (field !== '' || row.length) {
|
|
112
|
+
row.push(field);
|
|
113
|
+
rows.push(row);
|
|
114
|
+
}
|
|
115
|
+
return rows.filter((entry) => entry.some((value) => value.trim() !== ''));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `[{ header: value }]` for a CSV with these headers, or `{ error }`. */
|
|
119
|
+
function readTable(text, required, label) {
|
|
120
|
+
const rows = parseCsv(text);
|
|
121
|
+
if (!rows.length) return { error: `The ${label} file is empty.` };
|
|
122
|
+
const headers = rows[0].map((value) => value.trim());
|
|
123
|
+
const missing = required.filter((name) => !headers.includes(name));
|
|
124
|
+
if (missing.length) {
|
|
125
|
+
return { error: `The ${label} file is not a DeepSeek usage export: it lacks the column${missing.length === 1 ? '' : 's'} ${missing.join(', ')}.` };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
rows: rows.slice(1).map((values, index) => {
|
|
129
|
+
const entry = { line: index + 2 };
|
|
130
|
+
headers.forEach((name, column) => { entry[name] = String(values[column] ?? '').trim(); });
|
|
131
|
+
return entry;
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An ISO time with its offset whose fields are a real date and time
|
|
138
|
+
* (2026-02-30 or 25:00 is not): `{ date, ms }`, or null.
|
|
139
|
+
*/
|
|
140
|
+
export function readIsoWithOffset(value) {
|
|
141
|
+
const match = ISO_WITH_OFFSET.exec(String(value ?? ''));
|
|
142
|
+
if (!match) return null;
|
|
143
|
+
const [, year, month, dayOfMonth, hours, minutes, seconds = '00', zone, zoneHours = '00', zoneMinutes = '00'] = match;
|
|
144
|
+
const fields = [year, month, dayOfMonth, hours, minutes, seconds].map(Number);
|
|
145
|
+
const local = new Date(Date.UTC(fields[0], fields[1] - 1, fields[2], fields[3], fields[4], fields[5]));
|
|
146
|
+
const sameFields = local.getUTCFullYear() === fields[0]
|
|
147
|
+
&& local.getUTCMonth() === fields[1] - 1
|
|
148
|
+
&& local.getUTCDate() === fields[2]
|
|
149
|
+
&& local.getUTCHours() === fields[3]
|
|
150
|
+
&& local.getUTCMinutes() === fields[4]
|
|
151
|
+
&& local.getUTCSeconds() === fields[5];
|
|
152
|
+
if (!sameFields || Number(zoneHours) > 14 || Number(zoneMinutes) > 59) return null;
|
|
153
|
+
const ms = Date.parse(value);
|
|
154
|
+
if (!Number.isFinite(ms)) return null;
|
|
155
|
+
return { date: `${year}-${month}-${dayOfMonth}`, ms, zone };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function period(entry, label) {
|
|
159
|
+
const start = readIsoWithOffset(entry.start_time_iso);
|
|
160
|
+
const end = readIsoWithOffset(entry.end_time_iso);
|
|
161
|
+
if (!start || !end || end.ms <= start.ms) {
|
|
162
|
+
return { error: `Line ${entry.line} of the ${label} file has no readable period.` };
|
|
163
|
+
}
|
|
164
|
+
return { start: entry.start_time_iso, end: entry.end_time_iso, endMs: end.ms, day: start.date };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A plain decimal, null for an empty field, NaN for anything else. */
|
|
168
|
+
export function readDecimal(value) {
|
|
169
|
+
if (value === '') return null;
|
|
170
|
+
if (!DECIMAL.test(value)) return NaN;
|
|
171
|
+
const parsed = Number(value);
|
|
172
|
+
return Number.isFinite(parsed) ? parsed : NaN;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const number = readDecimal;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The two files, checked against each other: `{ ok: true, rows, skipped }`
|
|
179
|
+
* with one row per export day, model and key, or `{ ok: false, error }`.
|
|
180
|
+
* `rows` leaves out the days with a period that ends after `now`; `skipped`
|
|
181
|
+
* names those periods.
|
|
182
|
+
*/
|
|
183
|
+
export function parseDeepSeekUsageExport({ amount, cost, now = new Date() } = {}) {
|
|
184
|
+
const amounts = readTable(amount, AMOUNT_HEADERS, 'amount');
|
|
185
|
+
if (amounts.error) return { ok: false, error: amounts.error };
|
|
186
|
+
const costs = readTable(cost, COST_HEADERS, 'cost');
|
|
187
|
+
if (costs.error) return { ok: false, error: costs.error };
|
|
188
|
+
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
|
|
189
|
+
|
|
190
|
+
const rows = new Map();
|
|
191
|
+
const computed = new Map();
|
|
192
|
+
const skipped = new Set();
|
|
193
|
+
const unfinishedDays = new Set();
|
|
194
|
+
for (const entry of amounts.rows) {
|
|
195
|
+
const when = period(entry, 'amount');
|
|
196
|
+
if (when.error) return { ok: false, error: when.error };
|
|
197
|
+
const model = entry.model.toLowerCase();
|
|
198
|
+
if (!MODEL_PATTERN.test(model)) return { ok: false, error: `Line ${entry.line} of the amount file has no readable model.` };
|
|
199
|
+
const keyFp = deepseekKeyFingerprint(entry.api_key);
|
|
200
|
+
if (!keyFp) return { ok: false, error: `Line ${entry.line} of the amount file has no readable API key.` };
|
|
201
|
+
const price = number(entry.price);
|
|
202
|
+
const quantity = number(entry.amount);
|
|
203
|
+
if (Number.isNaN(price) || quantity === null || Number.isNaN(quantity)) {
|
|
204
|
+
return { ok: false, error: `Line ${entry.line} of the amount file has no readable price or amount.` };
|
|
205
|
+
}
|
|
206
|
+
if (when.endMs > nowMs) {
|
|
207
|
+
skipped.add(when.start);
|
|
208
|
+
unfinishedDays.add(when.day);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const periodKey = `${when.start}\u0000${model}`;
|
|
212
|
+
const rowKey = `${when.day}\u0000${model}\u0000${keyFp}`;
|
|
213
|
+
const row = rows.get(rowKey) || {
|
|
214
|
+
periodStart: when.start,
|
|
215
|
+
periodEnd: when.end,
|
|
216
|
+
day: when.day,
|
|
217
|
+
model,
|
|
218
|
+
keyFp,
|
|
219
|
+
keyName: entry.api_key_name,
|
|
220
|
+
hitTokens: 0,
|
|
221
|
+
missTokens: 0,
|
|
222
|
+
outputTokens: 0,
|
|
223
|
+
requestCount: 0,
|
|
224
|
+
cost: 0,
|
|
225
|
+
currency: '',
|
|
226
|
+
};
|
|
227
|
+
if (when.start < row.periodStart) row.periodStart = when.start;
|
|
228
|
+
if (when.end > row.periodEnd) row.periodEnd = when.end;
|
|
229
|
+
const field = TYPE_FIELDS[entry.type];
|
|
230
|
+
if (field) row[field] += Math.round(quantity);
|
|
231
|
+
const rowCost = price === null ? 0 : quantity * price;
|
|
232
|
+
row.cost += rowCost;
|
|
233
|
+
rows.set(rowKey, row);
|
|
234
|
+
computed.set(periodKey, (computed.get(periodKey) || 0) + rowCost);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const charged = new Map();
|
|
238
|
+
const currencies = new Map();
|
|
239
|
+
const allCurrencies = new Set();
|
|
240
|
+
for (const entry of costs.rows) {
|
|
241
|
+
const when = period(entry, 'cost');
|
|
242
|
+
if (when.error) return { ok: false, error: when.error };
|
|
243
|
+
if (when.endMs > nowMs) continue;
|
|
244
|
+
const model = entry.model.toLowerCase();
|
|
245
|
+
const value = number(entry.cost);
|
|
246
|
+
if (value === null || Number.isNaN(value)) return { ok: false, error: `Line ${entry.line} of the cost file has no readable cost.` };
|
|
247
|
+
const currency = entry.currency.toUpperCase();
|
|
248
|
+
if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: `Line ${entry.line} of the cost file has no readable currency.` };
|
|
249
|
+
const periodKey = `${when.start}\u0000${model}`;
|
|
250
|
+
charged.set(periodKey, (charged.get(periodKey) || 0) + value);
|
|
251
|
+
const seen = currencies.get(periodKey) || new Set();
|
|
252
|
+
seen.add(currency);
|
|
253
|
+
currencies.set(periodKey, seen);
|
|
254
|
+
allCurrencies.add(currency);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Every period and model of either file: the amounts' cost has to be what
|
|
258
|
+
// DeepSeek charged.
|
|
259
|
+
for (const periodKey of new Set([...computed.keys(), ...charged.keys()])) {
|
|
260
|
+
const [start, model] = periodKey.split('\u0000');
|
|
261
|
+
const fromAmounts = computed.get(periodKey) || 0;
|
|
262
|
+
const fromCosts = charged.get(periodKey) || 0;
|
|
263
|
+
if (Math.abs(fromAmounts - fromCosts) > DEEPSEEK_IMPORT_COST_TOLERANCE) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
error: `The files do not match for ${model} from ${start}: the amounts come to ${tenDecimals(fromAmounts)}, the cost file says ${tenDecimals(fromCosts)}. Export both files for the same range and import them together.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if ((currencies.get(periodKey)?.size || 0) > 1) {
|
|
270
|
+
return { ok: false, error: `The cost file has more than one currency for ${model} from ${start}.` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const soleCurrency = allCurrencies.size === 1 ? [...allCurrencies][0] : '';
|
|
275
|
+
// A day's periods share their currency (a day is one period in the
|
|
276
|
+
// platform's export); one with two is refused.
|
|
277
|
+
const dayCurrencies = new Map();
|
|
278
|
+
for (const [periodKey, seen] of currencies) {
|
|
279
|
+
const [start, model] = periodKey.split('\u0000');
|
|
280
|
+
const when = readIsoWithOffset(start);
|
|
281
|
+
const dayKey = `${when.date}\u0000${model}`;
|
|
282
|
+
const all = dayCurrencies.get(dayKey) || new Set();
|
|
283
|
+
for (const value of seen) all.add(value);
|
|
284
|
+
if (all.size > 1) return { ok: false, error: `The cost file has more than one currency for ${model} on ${when.date}.` };
|
|
285
|
+
dayCurrencies.set(dayKey, all);
|
|
286
|
+
}
|
|
287
|
+
const list = [...rows.values()]
|
|
288
|
+
.filter((row) => !unfinishedDays.has(row.day))
|
|
289
|
+
.map((row) => {
|
|
290
|
+
const seen = dayCurrencies.get(`${row.day}\u0000${row.model}`);
|
|
291
|
+
const currency = seen ? [...seen][0] : soleCurrency;
|
|
292
|
+
return { ...row, cost: tenDecimals(row.cost), currency };
|
|
293
|
+
});
|
|
294
|
+
list.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : a.model < b.model ? -1 : a.model > b.model ? 1 : a.keyFp < b.keyFp ? -1 : a.keyFp > b.keyFp ? 1 : 0));
|
|
295
|
+
return { ok: true, rows: list, skipped: [...skipped].sort() };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {object} options
|
|
300
|
+
* @param {object} options.db better-sqlite3 database
|
|
301
|
+
* @param {Function} [options.getApiKey] the saved DeepSeek key, or ''
|
|
302
|
+
* @param {Function} [options.now]
|
|
303
|
+
*/
|
|
304
|
+
export function createDeepSeekUsageImportService({ db, getApiKey = () => '', now = () => new Date() } = {}) {
|
|
305
|
+
if (!db || typeof db.prepare !== 'function') throw new Error('createDeepSeekUsageImportService requires a database');
|
|
306
|
+
migrateDeepSeekUsageActuals(db);
|
|
307
|
+
const clearKeyDay = db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE} WHERE key_fp = ? AND day = ?`);
|
|
308
|
+
const upsert = db.prepare(`
|
|
309
|
+
INSERT OR REPLACE INTO ${DEEPSEEK_USAGE_ACTUALS_TABLE}
|
|
310
|
+
(period_start, period_end, day, model, key_fp, key_name, hit_tokens, miss_tokens, output_tokens, request_count, cost, currency, imported_at)
|
|
311
|
+
VALUES (@periodStart, @periodEnd, @day, @model, @keyFp, @keyName, @hitTokens, @missTokens, @outputTokens, @requestCount, @cost, @currency, @importedAt)
|
|
312
|
+
`);
|
|
313
|
+
// Each key's days in the export replace what an earlier import had for
|
|
314
|
+
// them, whatever time zone that one was taken in.
|
|
315
|
+
const writeAll = db.transaction((rows, importedAt) => {
|
|
316
|
+
for (const keyDay of new Set(rows.map((row) => `${row.keyFp}\u0000${row.day}`))) {
|
|
317
|
+
clearKeyDay.run(...keyDay.split('\u0000'));
|
|
318
|
+
}
|
|
319
|
+
for (const row of rows) upsert.run({ ...row, importedAt });
|
|
320
|
+
});
|
|
321
|
+
const listForKey = db.prepare(`
|
|
322
|
+
SELECT day, model, currency,
|
|
323
|
+
SUM(hit_tokens) AS hit_tokens, SUM(miss_tokens) AS miss_tokens, SUM(output_tokens) AS output_tokens,
|
|
324
|
+
SUM(request_count) AS request_count, SUM(cost) AS cost, MAX(period_end) AS period_end
|
|
325
|
+
FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE}
|
|
326
|
+
WHERE key_fp = ?
|
|
327
|
+
GROUP BY day, model, currency
|
|
328
|
+
ORDER BY day, model
|
|
329
|
+
`);
|
|
330
|
+
// The days the imports cover, for every key: on them the export has what
|
|
331
|
+
// the account was charged, so the saved key's absence is a day without spend.
|
|
332
|
+
const listCoveredDays = db.prepare(`
|
|
333
|
+
SELECT DISTINCT day, currency FROM ${DEEPSEEK_USAGE_ACTUALS_TABLE} ORDER BY day, currency
|
|
334
|
+
`);
|
|
335
|
+
|
|
336
|
+
function savedKeyFp() {
|
|
337
|
+
try {
|
|
338
|
+
return deepseekKeyFingerprint(getApiKey?.() || '');
|
|
339
|
+
} catch {
|
|
340
|
+
return '';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Imports the two CSV texts: `{ ok: true, summary }` or `{ ok: false,
|
|
346
|
+
* error }`. The summary: `{ rows, days, models, keys: [{ keyFp, keyName,
|
|
347
|
+
* rows }], skippedPeriods, through, savedKeyFp, savedKeyMatched,
|
|
348
|
+
* currencies, cardCurrency, currencyMatched }`. `cardCurrency` is the
|
|
349
|
+
* usage card's currency when the caller knows it (null otherwise);
|
|
350
|
+
* `currencyMatched` says whether the card can use the saved key's rows.
|
|
351
|
+
*/
|
|
352
|
+
function importExport({ amount, cost, cardCurrency = null } = {}) {
|
|
353
|
+
const clock = now();
|
|
354
|
+
const parsed = parseDeepSeekUsageExport({ amount, cost, now: clock });
|
|
355
|
+
if (!parsed.ok) return parsed;
|
|
356
|
+
writeAll(parsed.rows, (clock instanceof Date ? clock : new Date(clock)).toISOString());
|
|
357
|
+
const keys = new Map();
|
|
358
|
+
for (const row of parsed.rows) {
|
|
359
|
+
const entry = keys.get(row.keyFp) || { keyFp: row.keyFp, keyName: row.keyName, rows: 0 };
|
|
360
|
+
entry.rows += 1;
|
|
361
|
+
keys.set(row.keyFp, entry);
|
|
362
|
+
}
|
|
363
|
+
const saved = savedKeyFp();
|
|
364
|
+
const card = /^[A-Z]{3}$/.test(String(cardCurrency || '').toUpperCase()) ? String(cardCurrency).toUpperCase() : null;
|
|
365
|
+
const savedRows = parsed.rows.filter((row) => row.keyFp === saved);
|
|
366
|
+
const currencies = [...new Set(parsed.rows.map((row) => row.currency).filter(Boolean))].sort();
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
summary: {
|
|
370
|
+
rows: parsed.rows.length,
|
|
371
|
+
days: [...new Set(parsed.rows.map((row) => row.day))],
|
|
372
|
+
models: [...new Set(parsed.rows.map((row) => row.model))].sort(),
|
|
373
|
+
keys: [...keys.values()],
|
|
374
|
+
skippedPeriods: parsed.skipped,
|
|
375
|
+
through: parsed.rows.reduce((latest, row) => (row.periodEnd > latest ? row.periodEnd : latest), '') || null,
|
|
376
|
+
savedKeyFp: saved || null,
|
|
377
|
+
savedKeyMatched: !!saved && keys.has(saved),
|
|
378
|
+
currencies,
|
|
379
|
+
cardCurrency: card,
|
|
380
|
+
currencyMatched: card ? savedRows.some((row) => row.currency === card) : null,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The saved key's imported figures per export day and model, for the
|
|
387
|
+
* usage card: `{ keyFp, through, rows: [{ day, model, hitTokens,
|
|
388
|
+
* missTokens, outputTokens, requestCount, cost, currency }], coveredDays:
|
|
389
|
+
* [{ day, currency }] }`, or null without a saved key or rows for it.
|
|
390
|
+
* `coveredDays` are the days any import covers, for any key: the card
|
|
391
|
+
* replaces the estimate on those days only.
|
|
392
|
+
*/
|
|
393
|
+
function readActuals() {
|
|
394
|
+
const keyFp = savedKeyFp();
|
|
395
|
+
if (!keyFp) return null;
|
|
396
|
+
const rows = listForKey.all(keyFp);
|
|
397
|
+
if (!rows.length) return null;
|
|
398
|
+
return {
|
|
399
|
+
keyFp,
|
|
400
|
+
through: rows.reduce((latest, row) => (row.period_end > latest ? row.period_end : latest), ''),
|
|
401
|
+
rows: rows.map((row) => ({
|
|
402
|
+
day: row.day,
|
|
403
|
+
model: row.model,
|
|
404
|
+
hitTokens: Number(row.hit_tokens) || 0,
|
|
405
|
+
missTokens: Number(row.miss_tokens) || 0,
|
|
406
|
+
outputTokens: Number(row.output_tokens) || 0,
|
|
407
|
+
requestCount: Number(row.request_count) || 0,
|
|
408
|
+
cost: tenDecimals(row.cost || 0),
|
|
409
|
+
currency: row.currency,
|
|
410
|
+
})),
|
|
411
|
+
coveredDays: listCoveredDays.all().map((row) => ({ day: row.day, currency: row.currency })),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { importExport, readActuals };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function defaultDbPath() {
|
|
419
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
420
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The CLI's work: reads the files, imports them into the database at `dbPath`. */
|
|
424
|
+
export function importDeepSeekUsageFiles({ amountPath, costPath, dbPath = defaultDbPath(), now = () => new Date() } = {}) {
|
|
425
|
+
const amount = fs.readFileSync(amountPath, 'utf8');
|
|
426
|
+
const cost = fs.readFileSync(costPath, 'utf8');
|
|
427
|
+
const db = new Database(dbPath);
|
|
428
|
+
try {
|
|
429
|
+
// The saved key, read straight from the app settings table like the relay does.
|
|
430
|
+
const getApiKey = () => {
|
|
431
|
+
try {
|
|
432
|
+
return String(db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(DEEPSEEK_API_KEY_SETTING_KEY)?.value || '').trim();
|
|
433
|
+
} catch {
|
|
434
|
+
return '';
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
return createDeepSeekUsageImportService({ db, getApiKey, now }).importExport({ amount, cost });
|
|
438
|
+
} finally {
|
|
439
|
+
db.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
444
|
+
if (isMain) {
|
|
445
|
+
const [amountArg, costArg, dbArg] = process.argv.slice(2);
|
|
446
|
+
if (!amountArg || !costArg) {
|
|
447
|
+
console.error('Usage: npm run deepseek:import -- amount.csv cost.csv [path/to/copilot.db]');
|
|
448
|
+
process.exit(2);
|
|
449
|
+
}
|
|
450
|
+
const dbPath = dbArg ? path.resolve(dbArg) : defaultDbPath();
|
|
451
|
+
try {
|
|
452
|
+
const result = importDeepSeekUsageFiles({ amountPath: path.resolve(amountArg), costPath: path.resolve(costArg), dbPath });
|
|
453
|
+
if (!result.ok) {
|
|
454
|
+
console.error(`DeepSeek usage import refused: ${result.error}`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
const { summary } = result;
|
|
458
|
+
console.log(`DeepSeek usage import on ${dbPath}`);
|
|
459
|
+
console.log(` rows ${summary.rows}, days ${summary.days.join(', ') || 'none'}, models ${summary.models.join(', ') || 'none'}`);
|
|
460
|
+
for (const key of summary.keys) console.log(` key ${key.keyFp}${key.keyName ? ` (${key.keyName})` : ''}: ${key.rows} rows`);
|
|
461
|
+
if (summary.currencies.length) console.log(` currency ${summary.currencies.join(', ')}: the usage card uses the rows in its own currency (the balance's) only`);
|
|
462
|
+
if (summary.skippedPeriods.length) console.log(` left out, not ended yet: ${summary.skippedPeriods.join(', ')}`);
|
|
463
|
+
console.log(summary.savedKeyFp
|
|
464
|
+
? ` saved key ${summary.savedKeyFp}: ${summary.savedKeyMatched ? 'in the export, the card counts its rows' : 'not in the export, the card is unchanged'}`
|
|
465
|
+
: ' no saved DeepSeek key: the card counts nothing until one is saved');
|
|
466
|
+
process.exit(0);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error(`DeepSeek usage import failed: ${err?.message || err}`);
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -130,6 +130,7 @@ export function modelProviderForId(modelId) {
|
|
|
130
130
|
if (text.startsWith('mai-')) return 'microsoft';
|
|
131
131
|
if (text.startsWith('grok-')) return 'xai';
|
|
132
132
|
if (text.startsWith('kimi-')) return 'moonshot';
|
|
133
|
+
if (text.startsWith('deepseek-')) return 'deepseek';
|
|
133
134
|
return 'other';
|
|
134
135
|
}
|
|
135
136
|
|