dsh-openrouter-spend 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +10 -7
- package/client.js +87 -15
- package/index.js +97 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Display currency in Settings: USD (default) or RUB, converted at the cbr.ru daily rate. The rate is read host-side with its own cache (`rateRefreshSeconds`), so a rate fetch never delays the spend summary.
|
|
13
|
+
- When cbr.ru cannot be reached, RUB falls back to USD with a visible note — "check that cbr.ru is reachable" — plus the fetch reason.
|
|
14
|
+
- `cbrUrl` and `rateRefreshSeconds` config fields.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- A negative prepaid balance prints at money precision instead of four decimals.
|
|
19
|
+
|
|
8
20
|
## [0.1.2] - 2026-10-05
|
|
9
21
|
|
|
10
22
|
### Added
|
package/README.md
CHANGED
|
@@ -19,18 +19,18 @@
|
|
|
19
19
|
## What you get
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
22
|
-
<img src="https://raw.githubusercontent.com/RyabykinIlya/dsh-openrouter-spend/v0.
|
|
22
|
+
<img src="https://raw.githubusercontent.com/RyabykinIlya/dsh-openrouter-spend/v0.2.0/docs/chip.png" width="640" alt="Spend chip under the composer"><br>
|
|
23
23
|
<sub>The chip under the composer: billed spend for the selected range, next to the session's token and context stats.</sub>
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
26
|
<p align="center">
|
|
27
|
-
<img src="https://raw.githubusercontent.com/RyabykinIlya/dsh-openrouter-spend/v0.
|
|
27
|
+
<img src="https://raw.githubusercontent.com/RyabykinIlya/dsh-openrouter-spend/v0.2.0/docs/popover.png" width="340" alt="Spend popover"><br>
|
|
28
28
|
<sub>The popover: range and API-key filters, today's spend per model, daily bars, prepaid balance and lifetime spend.</sub>
|
|
29
29
|
</p>
|
|
30
30
|
|
|
31
31
|
- **Chip under the composer** — spend for the selected range and API key: today, 7 days, or 30 days.
|
|
32
32
|
- **Chip popover** — range and API-key filters, today's spend per model, a daily bar chart across the window, prepaid balance and lifetime spend.
|
|
33
|
-
- **Settings → OpenRouter spend** — store or clear the management key, set the refresh interval, filter by API key.
|
|
33
|
+
- **Settings → OpenRouter spend** — store or clear the management key, pick the display currency (USD, or RUB at the cbr.ru rate), set the refresh interval, filter by API key.
|
|
34
34
|
|
|
35
35
|
The host half caches the summary for `refreshSeconds` and coalesces concurrent refreshes, so any number of open tabs cost one OpenRouter query per refresh window. When a refresh fails, the last good figures stay on screen and the reason is shown instead of an empty panel.
|
|
36
36
|
|
|
@@ -50,7 +50,7 @@ dsh plugin --profile web add dsh-openrouter-spend
|
|
|
50
50
|
From GitHub (plain JavaScript, no build step and no build-script permission):
|
|
51
51
|
|
|
52
52
|
```sh
|
|
53
|
-
dsh plugin --profile web add github:RyabykinIlya/dsh-openrouter-spend#v0.
|
|
53
|
+
dsh plugin --profile web add github:RyabykinIlya/dsh-openrouter-spend#v0.2.0
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Then open Settings → OpenRouter spend and paste the management key.
|
|
@@ -65,9 +65,11 @@ Every field can be overridden in the profile's `cordis.patch.yml` row; the schem
|
|
|
65
65
|
| `apiBase` | `https://openrouter.ai/api/v1` | `https://` URL | OpenRouter REST API root |
|
|
66
66
|
| `refreshSeconds` | `60` | 10–3600, integer | Seconds between OpenRouter refreshes |
|
|
67
67
|
| `historyDays` | `30` | 2–366, integer | Days of history the summary window covers, today included |
|
|
68
|
-
| `
|
|
68
|
+
| `cbrUrl` | `https://www.cbr.ru/scripts/XML_daily.asp` | `https://` URL | CBR daily-rates sheet read for the RUB rate |
|
|
69
|
+
| `rateRefreshSeconds` | `3600` | 60–86400, integer | Seconds between cbr.ru rate reads |
|
|
70
|
+
| `timeoutMs` | `15000` | 1000–120000, integer | Per-request deadline against OpenRouter and cbr.ru |
|
|
69
71
|
|
|
70
|
-
Viewing preferences (per-key filter, refresh interval override) are browser-local (`localStorage`).
|
|
72
|
+
Viewing preferences (display currency, per-key filter, refresh interval override) are browser-local (`localStorage`).
|
|
71
73
|
|
|
72
74
|
## What the numbers are
|
|
73
75
|
|
|
@@ -75,6 +77,7 @@ Viewing preferences (per-key filter, refresh interval override) are browser-loca
|
|
|
75
77
|
- "Today" is the current UTC day.
|
|
76
78
|
- Balance is `total_credits − total_usage` from `GET /api/v1/credits`: what remains of prepaid credits.
|
|
77
79
|
- The 7-day and 30-day ranges cover at most `historyDays` days; keep `historyDays ≥ 30` for full coverage.
|
|
80
|
+
- RUB display converts those USD figures at the cbr.ru daily rate, read host-side and cached for `rateRefreshSeconds`. When cbr.ru cannot be reached, the figures stay in USD and the panel says so, with the fetch reason and a hint to check the connection.
|
|
78
81
|
|
|
79
82
|
## Security
|
|
80
83
|
|
|
@@ -87,4 +90,4 @@ Viewing preferences (per-key filter, refresh interval override) are browser-loca
|
|
|
87
90
|
|
|
88
91
|
## По-русски
|
|
89
92
|
|
|
90
|
-
Плагин для DeepSeek Harness показывает реальные списания аккаунта OpenRouter (данные из аналитики OpenRouter, в долларах, по UTC-дням): чип под полем ввода с выбором периода сегодня / 7 дней / 30 дней, в поповере — разбивка по моделям за сегодня, график по дням, фильтр по API-ключам и остаток предоплаченных кредитов. В Settings → OpenRouter spend задаётся управляющий
|
|
93
|
+
Плагин для DeepSeek Harness показывает реальные списания аккаунта OpenRouter (данные из аналитики OpenRouter, в долларах, по UTC-дням): чип под полем ввода с выбором периода сегодня / 7 дней / 30 дней, в поповере — разбивка по моделям за сегодня, график по дням, фильтр по API-ключам и остаток предоплаченных кредитов. В Settings → OpenRouter spend задаётся управляющий ключ, валюта отображения (USD или RUB по курсу ЦБ РФ), интервал обновления и фильтр по API-ключу. Если cbr.ru недоступен, суммы остаются в USD, а панель просит проверить подключение к cbr.ru. Установка: `dsh plugin --profile web add dsh-openrouter-spend`; нужен Management API key из кабинета OpenRouter (Settings → Management API Keys) — обычный ключ модели для аналитики не подходит.
|
package/client.js
CHANGED
|
@@ -11,6 +11,7 @@ window.__ModuleLoader__.load({
|
|
|
11
11
|
/** Preferences are viewing choices: a browser-local store, not host state. */
|
|
12
12
|
const PREF_KEY_ID = 'ors.keyId';
|
|
13
13
|
const PREF_INTERVAL = 'ors.intervalSeconds';
|
|
14
|
+
const PREF_CURRENCY = 'ors.currency';
|
|
14
15
|
const PREFS_CHANGED = 'ors:prefs';
|
|
15
16
|
|
|
16
17
|
const CSS = `
|
|
@@ -83,13 +84,33 @@ window.__ModuleLoader__.load({
|
|
|
83
84
|
}
|
|
84
85
|
`;
|
|
85
86
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Money with the precision a sub-cent figure needs, and none it does not.
|
|
89
|
+
* USD prints as an invoice writes it (`$1.23`), RUB as a receipt does
|
|
90
|
+
* (`1.23 ₽`).
|
|
91
|
+
*/
|
|
92
|
+
function fmtMoney(value, currency) {
|
|
88
93
|
const amount = Number(value) || 0;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const magnitude = Math.abs(amount);
|
|
95
|
+
const digits = magnitude === 0 ? 2 : magnitude < 0.01 ? 4 : magnitude < 1 ? 3 : 2;
|
|
96
|
+
return currency === 'RUB' ? `${amount.toFixed(digits)} ₽` : `$${amount.toFixed(digits)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The display money for one currency choice. OpenRouter bills USD and the
|
|
101
|
+
* wire stays USD; RUB is that figure at the cbr.ru rate. Without a rate the
|
|
102
|
+
* USD presentation is kept — the caller adds the "check cbr.ru" note — so a
|
|
103
|
+
* dead cbr.ru costs the conversion, never the numbers.
|
|
104
|
+
*/
|
|
105
|
+
function makeMoney(currency, perUsd) {
|
|
106
|
+
const rate = Number(perUsd);
|
|
107
|
+
const rub = currency === 'RUB' && Number.isFinite(rate) && rate > 0;
|
|
108
|
+
return {
|
|
109
|
+
rub,
|
|
110
|
+
perUsd: rub ? rate : undefined,
|
|
111
|
+
symbol: rub ? '₽' : '$',
|
|
112
|
+
fmt: value => fmtMoney(rub ? (Number(value) || 0) * rate : value, rub ? 'RUB' : 'USD'),
|
|
113
|
+
};
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
const fmtInt = value => Number(value || 0).toLocaleString('en-US');
|
|
@@ -111,6 +132,11 @@ window.__ModuleLoader__.load({
|
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
|
|
135
|
+
/** The display currency preference; anything else in storage reads as USD. */
|
|
136
|
+
function readCurrency() {
|
|
137
|
+
return readPref(PREF_CURRENCY, 'USD') === 'RUB' ? 'RUB' : 'USD';
|
|
138
|
+
}
|
|
139
|
+
|
|
114
140
|
/** Store one viewing preference and tell the other component about it. */
|
|
115
141
|
function writePref(name, value) {
|
|
116
142
|
try {
|
|
@@ -136,6 +162,7 @@ window.__ModuleLoader__.load({
|
|
|
136
162
|
const [range, setRange] = useState('today');
|
|
137
163
|
const [keyId, setKeyId] = useState(() => readPref(PREF_KEY_ID, ''));
|
|
138
164
|
const [intervalSeconds, setIntervalSeconds] = useState(() => Number(readPref(PREF_INTERVAL, '0')));
|
|
165
|
+
const [currency, setCurrency] = useState(readCurrency);
|
|
139
166
|
const rootRef = useRef(null);
|
|
140
167
|
|
|
141
168
|
useEffect(() => { void fetchSummary().then(reply => { if (reply !== undefined) setData(reply); }); }, []);
|
|
@@ -153,6 +180,7 @@ window.__ModuleLoader__.load({
|
|
|
153
180
|
const sync = () => {
|
|
154
181
|
setKeyId(readPref(PREF_KEY_ID, ''));
|
|
155
182
|
setIntervalSeconds(Number(readPref(PREF_INTERVAL, '0')));
|
|
183
|
+
setCurrency(readCurrency());
|
|
156
184
|
};
|
|
157
185
|
window.addEventListener(PREFS_CHANGED, sync);
|
|
158
186
|
return () => window.removeEventListener(PREFS_CHANGED, sync);
|
|
@@ -196,6 +224,9 @@ window.__ModuleLoader__.load({
|
|
|
196
224
|
const peak = bars.reduce((max, entry) => Math.max(max, entry.usd), 0);
|
|
197
225
|
const balance = data?.credits ? data.credits.totalCredits - data.credits.totalUsage : undefined;
|
|
198
226
|
const rangeLabel = range === 'today' ? t('today') : range === 'last7' ? t('last7') : t('last30');
|
|
227
|
+
const money = makeMoney(currency, data?.rate?.perUsd);
|
|
228
|
+
// RUB was chosen but cbr.ru answered no rate: figures stay in USD and say so.
|
|
229
|
+
const rubUnavailable = currency === 'RUB' && data !== null && !money.rub;
|
|
199
230
|
|
|
200
231
|
if (data !== null && data.status === 'no-credential') {
|
|
201
232
|
return h('span', { className: 'ors-root' },
|
|
@@ -216,16 +247,22 @@ window.__ModuleLoader__.load({
|
|
|
216
247
|
onClick: () => setOpen(value => !value),
|
|
217
248
|
},
|
|
218
249
|
h('span', { className: 'ors-chip-dot', 'data-state': data?.status ?? 'loading' }),
|
|
219
|
-
h('span', { className: 'ors-chip-amount' }, data === null ? t('loading') :
|
|
250
|
+
h('span', { className: 'ors-chip-amount' }, data === null ? t('loading') : money.fmt(view.usd))),
|
|
220
251
|
open && h('div', { className: 'ors-panel' },
|
|
221
252
|
h('div', { className: 'ors-head' },
|
|
222
253
|
h('span', { className: 'ors-label' }, rangeLabel),
|
|
223
|
-
h('span', { className: 'ors-figure' },
|
|
254
|
+
h('span', { className: 'ors-figure' }, money.fmt(view.usd))),
|
|
224
255
|
h('div', { className: 'ors-sub' },
|
|
225
256
|
`${fmtInt(view.requests)} ${t('requests')}`
|
|
226
257
|
+ (data?.refreshedAt === undefined
|
|
227
258
|
? ''
|
|
228
259
|
: ` · ${t('updated')} ${new Date(data.refreshedAt).toLocaleTimeString()}`)),
|
|
260
|
+
money.rub && h('div', { className: 'ors-sub' },
|
|
261
|
+
`${t('cbrRate')}: 1 USD = ${money.perUsd.toFixed(2)} ₽`),
|
|
262
|
+
rubUnavailable && h('div', { className: 'ors-note' }, t('rubFallback')),
|
|
263
|
+
rubUnavailable && data?.rateError !== undefined
|
|
264
|
+
? h('div', { className: 'ors-sub' }, data.rateError)
|
|
265
|
+
: null,
|
|
229
266
|
h('div', { className: 'ors-range' },
|
|
230
267
|
['today', 'last7', 'last30'].map(id => h('button', {
|
|
231
268
|
key: id,
|
|
@@ -234,7 +271,7 @@ window.__ModuleLoader__.load({
|
|
|
234
271
|
onClick: () => setRange(id),
|
|
235
272
|
}, id === 'today' ? t('today') : id === 'last7' ? t('last7') : t('last30')))),
|
|
236
273
|
balance === undefined ? null : h('div', { className: 'ors-sub' },
|
|
237
|
-
`${t('balance')}: ${
|
|
274
|
+
`${t('balance')}: ${money.fmt(balance)} · ${t('lifetime')}: ${money.fmt(data.credits.totalUsage)}`),
|
|
238
275
|
keys.length > 0 && h('div', null,
|
|
239
276
|
h('div', { className: 'ors-sub' }, t('byKey')),
|
|
240
277
|
h('div', { className: 'ors-keyrow' },
|
|
@@ -250,13 +287,13 @@ window.__ModuleLoader__.load({
|
|
|
250
287
|
h('table', { className: 'ors-table' },
|
|
251
288
|
h('thead', null, h('tr', null,
|
|
252
289
|
h('th', null, t('byModel')),
|
|
253
|
-
h('th', null,
|
|
290
|
+
h('th', null, money.symbol),
|
|
254
291
|
h('th', null, t('requests')))),
|
|
255
292
|
h('tbody', null, rows.length === 0
|
|
256
293
|
? h('tr', null, h('td', { colSpan: 3, className: 'ors-label' }, '—'))
|
|
257
294
|
: rows.map(entry => h('tr', { key: entry.id },
|
|
258
295
|
h('td', null, entry.id),
|
|
259
|
-
h('td', { className: 'ors-num' },
|
|
296
|
+
h('td', { className: 'ors-num' }, money.fmt(entry.usd)),
|
|
260
297
|
h('td', { className: 'ors-num' }, fmtInt(entry.requests)))))),
|
|
261
298
|
bars.length > 0 && peak > 0 && h('div', { className: 'ors-bars' },
|
|
262
299
|
bars.map(entry => h('div', {
|
|
@@ -264,7 +301,7 @@ window.__ModuleLoader__.load({
|
|
|
264
301
|
className: 'ors-bar',
|
|
265
302
|
'data-today': entry.date === data.today,
|
|
266
303
|
style: { height: `${Math.max(2, Math.round((entry.usd / peak) * 34))}px` },
|
|
267
|
-
title: `${entry.date} · ${
|
|
304
|
+
title: `${entry.date} · ${money.fmt(entry.usd)}`,
|
|
268
305
|
}))),
|
|
269
306
|
data?.error === undefined ? null : h('div', { className: 'ors-note' }, data.error)));
|
|
270
307
|
}
|
|
@@ -274,6 +311,7 @@ window.__ModuleLoader__.load({
|
|
|
274
311
|
const [data, setData] = useState(null);
|
|
275
312
|
const [keyId, setKeyId] = useState(() => readPref(PREF_KEY_ID, ''));
|
|
276
313
|
const [intervalSeconds, setIntervalSeconds] = useState(() => readPref(PREF_INTERVAL, '0'));
|
|
314
|
+
const [currency, setCurrency] = useState(readCurrency);
|
|
277
315
|
const [draft, setDraft] = useState('');
|
|
278
316
|
const [notice, setNotice] = useState(null);
|
|
279
317
|
|
|
@@ -308,6 +346,9 @@ window.__ModuleLoader__.load({
|
|
|
308
346
|
|
|
309
347
|
const credential = data?.credential;
|
|
310
348
|
const keys = data?.byKey ?? [];
|
|
349
|
+
const money = makeMoney(currency, data?.rate?.perUsd);
|
|
350
|
+
// RUB was chosen but cbr.ru answered no rate: figures stay in USD and say so.
|
|
351
|
+
const rubUnavailable = currency === 'RUB' && data !== null && !money.rub;
|
|
311
352
|
const selectKey = value => {
|
|
312
353
|
setKeyId(value);
|
|
313
354
|
writePref(PREF_KEY_ID, value);
|
|
@@ -316,6 +357,10 @@ window.__ModuleLoader__.load({
|
|
|
316
357
|
setIntervalSeconds(value);
|
|
317
358
|
writePref(PREF_INTERVAL, value);
|
|
318
359
|
};
|
|
360
|
+
const selectCurrency = value => {
|
|
361
|
+
setCurrency(value);
|
|
362
|
+
writePref(PREF_CURRENCY, value);
|
|
363
|
+
};
|
|
319
364
|
|
|
320
365
|
return h('div', { className: 'ors-settings' },
|
|
321
366
|
h('style', null, CSS),
|
|
@@ -323,19 +368,31 @@ window.__ModuleLoader__.load({
|
|
|
323
368
|
h('div', { className: 'ors-head' },
|
|
324
369
|
h('span', { className: 'ors-label' }, t('today')),
|
|
325
370
|
h('span', { className: 'ors-figure' },
|
|
326
|
-
|
|
371
|
+
money.fmt(data?.todaySpend?.usd ?? 0))),
|
|
327
372
|
h('div', { className: 'ors-sub' },
|
|
328
373
|
`${fmtInt(data?.todaySpend?.requests ?? 0)} ${t('requests')}`
|
|
329
374
|
+ (data?.credits === undefined
|
|
330
375
|
? ''
|
|
331
|
-
: ` · ${t('balance')}: ${
|
|
376
|
+
: ` · ${t('balance')}: ${money.fmt(data.credits.totalCredits - data.credits.totalUsage)}`)),
|
|
332
377
|
data?.error === undefined ? null : h('div', { className: 'ors-note' }, data.error)),
|
|
378
|
+
h('div', { className: 'ors-field' },
|
|
379
|
+
h('label', { className: 'ors-sub' }, t('currency')),
|
|
380
|
+
h('select', { value: currency, onChange: event => selectCurrency(event.target.value) },
|
|
381
|
+
h('option', { value: 'USD' }, t('currencyUsd')),
|
|
382
|
+
h('option', { value: 'RUB' }, t('currencyRub'))),
|
|
383
|
+
money.rub
|
|
384
|
+
? h('div', { className: 'ors-sub' }, `${t('cbrRate')}: 1 USD = ${money.perUsd.toFixed(2)} ₽`)
|
|
385
|
+
: null,
|
|
386
|
+
rubUnavailable ? h('div', { className: 'ors-note' }, t('rubFallback')) : null,
|
|
387
|
+
rubUnavailable && data?.rateError !== undefined
|
|
388
|
+
? h('div', { className: 'ors-sub' }, data.rateError)
|
|
389
|
+
: null),
|
|
333
390
|
h('div', { className: 'ors-field' },
|
|
334
391
|
h('label', { className: 'ors-sub' }, t('countByKey')),
|
|
335
392
|
h('select', { value: keyId, onChange: event => selectKey(event.target.value) },
|
|
336
393
|
h('option', { value: '' }, t('allKeys')),
|
|
337
394
|
keys.map(entry => h('option', { key: entry.id, value: entry.id },
|
|
338
|
-
`${entry.id} · ${
|
|
395
|
+
`${entry.id} · ${money.fmt(entry.todayUsd)} ${t('today')}`)),
|
|
339
396
|
keyId !== '' && keys.every(entry => entry.id !== keyId)
|
|
340
397
|
? h('option', { value: keyId }, `${keyId} · ${t('unknownKey')}`)
|
|
341
398
|
: null)),
|
|
@@ -400,6 +457,11 @@ window.__ModuleLoader__.load({
|
|
|
400
457
|
countByKey: 'Count only this API key',
|
|
401
458
|
refreshEvery: 'Refresh every',
|
|
402
459
|
serverDefault: 'Plugin default',
|
|
460
|
+
currency: 'Display currency',
|
|
461
|
+
currencyUsd: 'USD (billed by OpenRouter)',
|
|
462
|
+
currencyRub: 'RUB (converted at the CBR rate)',
|
|
463
|
+
cbrRate: 'CBR rate',
|
|
464
|
+
rubFallback: 'RUB is unavailable: the rate could not be fetched from cbr.ru. Amounts are shown in USD — check that cbr.ru is reachable.',
|
|
403
465
|
managementKey: 'Management API key',
|
|
404
466
|
keyStored: 'Stored as',
|
|
405
467
|
keyHint: 'Needs an OpenRouter management key: Settings → API keys → Management.',
|
|
@@ -427,6 +489,11 @@ window.__ModuleLoader__.load({
|
|
|
427
489
|
countByKey: '只统计这个 API 密钥',
|
|
428
490
|
refreshEvery: '刷新间隔',
|
|
429
491
|
serverDefault: '插件默认',
|
|
492
|
+
currency: '显示货币',
|
|
493
|
+
currencyUsd: 'USD(OpenRouter 计费货币)',
|
|
494
|
+
currencyRub: 'RUB(按俄罗斯央行汇率换算)',
|
|
495
|
+
cbrRate: '央行汇率',
|
|
496
|
+
rubFallback: 'RUB 不可用:无法从 cbr.ru 获取汇率。金额以 USD 显示——请检查到 cbr.ru 的连接。',
|
|
430
497
|
managementKey: '管理密钥',
|
|
431
498
|
keyStored: '已保存为',
|
|
432
499
|
keyHint: '需要 OpenRouter 管理密钥:Settings → API keys → Management。',
|
|
@@ -454,6 +521,11 @@ window.__ModuleLoader__.load({
|
|
|
454
521
|
countByKey: 'Учитывать только этот API-ключ',
|
|
455
522
|
refreshEvery: 'Обновлять каждые',
|
|
456
523
|
serverDefault: 'По умолчанию',
|
|
524
|
+
currency: 'Валюта',
|
|
525
|
+
currencyUsd: 'USD (как списывает OpenRouter)',
|
|
526
|
+
currencyRub: 'RUB (по курсу ЦБ РФ)',
|
|
527
|
+
cbrRate: 'Курс ЦБ',
|
|
528
|
+
rubFallback: 'RUB недоступен: не удалось получить курс с cbr.ru. Суммы показаны в USD — проверьте подключение к cbr.ru.',
|
|
457
529
|
managementKey: 'Управляющий API-ключ',
|
|
458
530
|
keyStored: 'Сохранён как',
|
|
459
531
|
keyHint: 'Нужен управляющий ключ OpenRouter: Settings → API keys → Management.',
|
package/index.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Reads what the OpenRouter account actually charged with a management key and
|
|
5
5
|
* serves it to the browser half as one cached summary. Every figure comes from
|
|
6
6
|
* OpenRouter's own analytics (`total_usage`), so it is billed money rather than
|
|
7
|
-
* a token estimate priced from a local table.
|
|
7
|
+
* a token estimate priced from a local table. The one figure that is not
|
|
8
|
+
* OpenRouter's is the RUB rate, read from the CBR daily sheet so the browser
|
|
9
|
+
* can show the same money in rubles.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import Schema from 'schemastery'
|
|
@@ -30,6 +32,9 @@ const MAX_REPLY_BYTES = 4 * 1024 * 1024
|
|
|
30
32
|
/** Rows beyond this are refused rather than truncated: a partial split misreports cost. */
|
|
31
33
|
const MAX_ANALYTICS_ROWS = 500
|
|
32
34
|
|
|
35
|
+
/** The CBR sheet is a dozen kilobytes; a larger reply is not a rate sheet. */
|
|
36
|
+
const MAX_RATE_BYTES = 256 * 1024
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* The row's `config` schema: validated by the Loader before `apply`, so a
|
|
35
40
|
* wrong value fails the row at activation instead of misreporting spend later.
|
|
@@ -45,8 +50,12 @@ export const Config = Schema.object({
|
|
|
45
50
|
.description('Seconds between OpenRouter refreshes; one poll serves every open tab.'),
|
|
46
51
|
historyDays: Schema.number().step(1).min(2).max(366).default(30)
|
|
47
52
|
.description('Days of history the summary window covers, today included.'),
|
|
53
|
+
cbrUrl: Schema.string().pattern(/^https:\/\//).default('https://www.cbr.ru/scripts/XML_daily.asp')
|
|
54
|
+
.description('CBR daily-rates sheet read for the RUB rate; https only.'),
|
|
55
|
+
rateRefreshSeconds: Schema.number().step(1).min(60).max(86_400).default(3600)
|
|
56
|
+
.description('Seconds between cbr.ru rate reads; the rate moves at most once a day.'),
|
|
48
57
|
timeoutMs: Schema.number().step(1).min(1000).max(120_000).default(15_000)
|
|
49
|
-
.description('Per-request deadline against the OpenRouter API, in milliseconds.'),
|
|
58
|
+
.description('Per-request deadline against the OpenRouter API and cbr.ru, in milliseconds.'),
|
|
50
59
|
})
|
|
51
60
|
|
|
52
61
|
/**
|
|
@@ -114,6 +123,44 @@ async function queryCredits(config, credential, signal) {
|
|
|
114
123
|
return { totalCredits: data.total_credits, totalUsage: data.total_usage }
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Read one numeric tag (`<Value>`, `<Nominal>`) out of a `<Valute>` block.
|
|
128
|
+
* The sheet is windows-1251, but every tag read here is ASCII, so decoding the
|
|
129
|
+
* reply as UTF-8 cannot corrupt a rate; the garbled names are never touched.
|
|
130
|
+
*/
|
|
131
|
+
function readRateTag(block, tag) {
|
|
132
|
+
const match = block.match(new RegExp(`<${tag}>\\s*([\\d\\s,\\.]+)\\s*</${tag}>`))
|
|
133
|
+
return match === null ? undefined : match[1].replace(/\s/g, '').replace(',', '.')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pull the USD rate out of a CBR daily-rates sheet.
|
|
138
|
+
* @returns rubles for one US dollar.
|
|
139
|
+
*/
|
|
140
|
+
function parseUsdRub(sheet) {
|
|
141
|
+
const blocks = sheet.match(/<Valute[\s\S]*?<\/Valute>/g) ?? []
|
|
142
|
+
const usd = blocks.find(block => /<CharCode>\s*USD\s*<\/CharCode>/.test(block))
|
|
143
|
+
if (usd === undefined) throw new Error('cbr daily sheet carries no USD row')
|
|
144
|
+
// `Value` is the price of `Nominal` dollars; the rate is their quotient.
|
|
145
|
+
const value = Number(readRateTag(usd, 'Value'))
|
|
146
|
+
const nominal = Number(readRateTag(usd, 'Nominal') ?? '1')
|
|
147
|
+
const perUsd = value / nominal
|
|
148
|
+
if (!Number.isFinite(perUsd) || perUsd <= 0) throw new Error('cbr daily sheet carries no usable USD rate')
|
|
149
|
+
return perUsd
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Fetch today's USD rate from the CBR daily sheet; one number out of the XML. */
|
|
153
|
+
async function queryUsdRub(config, signal) {
|
|
154
|
+
const response = await fetch(config.cbrUrl, {
|
|
155
|
+
headers: { accept: 'application/xml, text/xml' },
|
|
156
|
+
signal,
|
|
157
|
+
})
|
|
158
|
+
if (!response.ok) throw new Error(`cbr daily sheet answered ${response.status}`)
|
|
159
|
+
const sheet = await response.text()
|
|
160
|
+
if (sheet.length > MAX_RATE_BYTES) throw new Error(`cbr daily sheet exceeds ${MAX_RATE_BYTES} bytes`)
|
|
161
|
+
return parseUsdRub(sheet)
|
|
162
|
+
}
|
|
163
|
+
|
|
117
164
|
/** UTC calendar day of a timestamp, the bucket OpenRouter answers `date__day` with. */
|
|
118
165
|
function utcDay(now) {
|
|
119
166
|
return now.toISOString().slice(0, 10)
|
|
@@ -217,14 +264,17 @@ function byModel(rows, today) {
|
|
|
217
264
|
/**
|
|
218
265
|
* Build the one summary the browser half renders: today's spend, trailing
|
|
219
266
|
* windows, the daily series, per-model and per-key splits, and the balance.
|
|
267
|
+
* `rateInfo` answers the RUB rate (or the reason it is missing) on the same
|
|
268
|
+
* refresh, so RUB display costs no extra round trip.
|
|
220
269
|
*/
|
|
221
|
-
async function collect(config, credential, signal) {
|
|
270
|
+
async function collect(config, credential, signal, rateInfo) {
|
|
222
271
|
const now = new Date()
|
|
223
272
|
const end = new Date(now.getTime() + 60_000).toISOString()
|
|
224
273
|
const start = startOfUtcDay(now, config.historyDays - 1)
|
|
225
|
-
const [rows, credits] = await Promise.all([
|
|
274
|
+
const [rows, credits, rate] = await Promise.all([
|
|
226
275
|
queryAnalytics(config, credential, start, end, signal),
|
|
227
276
|
queryCredits(config, credential, signal),
|
|
277
|
+
rateInfo(),
|
|
228
278
|
])
|
|
229
279
|
const today = utcDay(now)
|
|
230
280
|
const series = dailySeries(rows, config.historyDays, now)
|
|
@@ -240,6 +290,9 @@ async function collect(config, credential, signal) {
|
|
|
240
290
|
byDay: series,
|
|
241
291
|
byModel: byModel(rows, today),
|
|
242
292
|
byKey: byKey(rows, today, series),
|
|
293
|
+
// `rate` is always present: rubles per dollar, or null when cbr.ru failed
|
|
294
|
+
// — then `rateError` carries the reason for the panel to show.
|
|
295
|
+
...rate,
|
|
243
296
|
credits,
|
|
244
297
|
}
|
|
245
298
|
}
|
|
@@ -295,6 +348,7 @@ function emptyPayload(config) {
|
|
|
295
348
|
byDay: [],
|
|
296
349
|
byModel: [],
|
|
297
350
|
byKey: [],
|
|
351
|
+
rate: null,
|
|
298
352
|
}
|
|
299
353
|
}
|
|
300
354
|
|
|
@@ -305,6 +359,44 @@ function emptyPayload(config) {
|
|
|
305
359
|
export function apply(ctx, config) {
|
|
306
360
|
let cached = undefined
|
|
307
361
|
let inFlight = undefined
|
|
362
|
+
let rateCached = undefined
|
|
363
|
+
let rateInFlight = undefined
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The RUB rate, on its own clock: the CBR sheet moves at most once a day, so
|
|
367
|
+
* a good rate is kept for `rateRefreshSeconds`. A failure is kept for one
|
|
368
|
+
* refresh window only — a down cbr.ru is retried gently but recovers fast.
|
|
369
|
+
* Never throws: it answers `{ rate }`, or `{ rate: null, rateError }` for
|
|
370
|
+
* the payload to carry to the panel.
|
|
371
|
+
*/
|
|
372
|
+
const rateInfo = async () => {
|
|
373
|
+
const fresh = rateCached !== undefined && Date.now() - rateCached.fetchedAt < rateCached.ttlMs
|
|
374
|
+
if (fresh) return rateCached.value
|
|
375
|
+
if (rateInFlight !== undefined) return rateInFlight
|
|
376
|
+
rateInFlight = (async () => {
|
|
377
|
+
const now = Date.now()
|
|
378
|
+
try {
|
|
379
|
+
const perUsd = await queryUsdRub(config, AbortSignal.timeout(config.timeoutMs))
|
|
380
|
+
rateCached = {
|
|
381
|
+
fetchedAt: now,
|
|
382
|
+
ttlMs: config.rateRefreshSeconds * 1000,
|
|
383
|
+
value: { rate: { perUsd, fetchedAt: new Date(now).toISOString() } },
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
387
|
+
ctx.logger.warn('openrouter-spend: cbr rate unavailable', reason)
|
|
388
|
+
rateCached = {
|
|
389
|
+
fetchedAt: now,
|
|
390
|
+
ttlMs: config.refreshSeconds * 1000,
|
|
391
|
+
value: { rate: null, rateError: reason },
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
rateInFlight = undefined
|
|
395
|
+
}
|
|
396
|
+
return rateCached.value
|
|
397
|
+
})()
|
|
398
|
+
return rateInFlight
|
|
399
|
+
}
|
|
308
400
|
|
|
309
401
|
const summary = async () => {
|
|
310
402
|
const resolved = await ctx.credentials.resolve(config.credentialRef)
|
|
@@ -320,7 +412,7 @@ export function apply(ctx, config) {
|
|
|
320
412
|
}
|
|
321
413
|
const signal = AbortSignal.timeout(config.timeoutMs)
|
|
322
414
|
try {
|
|
323
|
-
return { ...(await collect(config, resolved.value, signal)), credential }
|
|
415
|
+
return { ...(await collect(config, resolved.value, signal, rateInfo)), credential }
|
|
324
416
|
} catch (error) {
|
|
325
417
|
// A stale reply beats an empty panel: keep the last good figures and say why.
|
|
326
418
|
const reason = error instanceof Error ? error.message : String(error)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-openrouter-spend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: shows what the OpenRouter account actually charged today — under the composer and in Settings, split by model and API key.",
|
|
5
5
|
"keywords": ["dsh", "dsh-plugin", "cordis", "openrouter", "spend", "cost", "analytics"],
|
|
6
6
|
"license": "MIT",
|