dsh-openrouter-spend 0.1.1 → 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 CHANGED
@@ -5,6 +5,24 @@ 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
+
20
+ ## [0.1.2] - 2026-10-05
21
+
22
+ ### Added
23
+
24
+ - Screenshots of the composer chip and the spend popover in the README.
25
+
8
26
  ## [0.1.1] - 2026-10-05
9
27
 
10
28
  ### Fixed
package/README.md CHANGED
@@ -18,9 +18,19 @@
18
18
 
19
19
  ## What you get
20
20
 
21
+ <p align="center">
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
+ <sub>The chip under the composer: billed spend for the selected range, next to the session's token and context stats.</sub>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/RyabykinIlya/dsh-openrouter-spend/v0.2.0/docs/popover.png" width="340" alt="Spend popover"><br>
28
+ <sub>The popover: range and API-key filters, today's spend per model, daily bars, prepaid balance and lifetime spend.</sub>
29
+ </p>
30
+
21
31
  - **Chip under the composer** — spend for the selected range and API key: today, 7 days, or 30 days.
22
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.
23
- - **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.
24
34
 
25
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.
26
36
 
@@ -40,7 +50,7 @@ dsh plugin --profile web add dsh-openrouter-spend
40
50
  From GitHub (plain JavaScript, no build step and no build-script permission):
41
51
 
42
52
  ```sh
43
- dsh plugin --profile web add github:RyabykinIlya/dsh-openrouter-spend#v0.1.1
53
+ dsh plugin --profile web add github:RyabykinIlya/dsh-openrouter-spend#v0.2.0
44
54
  ```
45
55
 
46
56
  Then open Settings → OpenRouter spend and paste the management key.
@@ -55,9 +65,11 @@ Every field can be overridden in the profile's `cordis.patch.yml` row; the schem
55
65
  | `apiBase` | `https://openrouter.ai/api/v1` | `https://` URL | OpenRouter REST API root |
56
66
  | `refreshSeconds` | `60` | 10–3600, integer | Seconds between OpenRouter refreshes |
57
67
  | `historyDays` | `30` | 2–366, integer | Days of history the summary window covers, today included |
58
- | `timeoutMs` | `15000` | 1000–120000, integer | Per-request deadline against OpenRouter |
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 |
59
71
 
60
- 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`).
61
73
 
62
74
  ## What the numbers are
63
75
 
@@ -65,6 +77,7 @@ Viewing preferences (per-key filter, refresh interval override) are browser-loca
65
77
  - "Today" is the current UTC day.
66
78
  - Balance is `total_credits − total_usage` from `GET /api/v1/credits`: what remains of prepaid credits.
67
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.
68
81
 
69
82
  ## Security
70
83
 
@@ -77,4 +90,4 @@ Viewing preferences (per-key filter, refresh interval override) are browser-loca
77
90
 
78
91
  ## По-русски
79
92
 
80
- Плагин для DeepSeek Harness показывает реальные списания аккаунта OpenRouter (данные из аналитики OpenRouter, в долларах, по UTC-дням): чип под полем ввода с выбором периода сегодня / 7 дней / 30 дней, в поповере — разбивка по моделям за сегодня, график по дням, фильтр по API-ключам и остаток предоплаченных кредитов. В Settings → OpenRouter spend задаётся управляющий ключ и интервал обновления. Установка: `dsh plugin --profile web add dsh-openrouter-spend`; нужен Management API key из кабинета OpenRouter (Settings → Management API Keys) — обычный ключ модели для аналитики не подходит.
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
- /** Money with the precision a sub-cent figure needs, and none it does not. */
87
- function fmtUsd(value) {
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
- if (amount === 0) return '$0.00';
90
- if (amount < 0.01) return `$${amount.toFixed(4)}`;
91
- if (amount < 1) return `$${amount.toFixed(3)}`;
92
- return `$${amount.toFixed(2)}`;
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') : fmtUsd(view.usd))),
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' }, fmtUsd(view.usd))),
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')}: ${fmtUsd(balance)} · ${t('lifetime')}: ${fmtUsd(data.credits.totalUsage)}`),
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' }, fmtUsd(entry.usd)),
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} · ${fmtUsd(entry.usd)}`,
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
- fmtUsd(data?.todaySpend?.usd ?? 0))),
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')}: ${fmtUsd(data.credits.totalCredits - data.credits.totalUsage)}`)),
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} · ${fmtUsd(entry.todayUsd)} ${t('today')}`)),
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.1.1",
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",