dsh-openrouter-spend 0.1.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 ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-10-05
9
+
10
+ ### Added
11
+
12
+ - Spend chip under the composer: today's cost with a 7/30-day popover, per-API-key filter, and per-model table.
13
+ - Settings page: daily bar chart, per-key totals, per-model split, prepaid balance, management-key storage, and browser-local refresh interval.
14
+ - `config` schema (`credentialRef`, `apiBase`, `refreshSeconds`, `historyDays`, `timeoutMs`) validated at activation.
15
+ - English, Russian, and Chinese display metadata and UI copy.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ilya Ryabykin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ <p align="center">
2
+ <img src="icon.svg" width="88" alt="OpenRouter spend icon">
3
+ </p>
4
+
5
+ <h1 align="center">dsh-openrouter-spend</h1>
6
+
7
+ <p align="center">
8
+ Real <a href="https://openrouter.ai">OpenRouter</a> spend inside
9
+ <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> —
10
+ billed money from OpenRouter's own analytics, not a token estimate priced from a local table.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://www.npmjs.com/package/dsh-openrouter-spend"><img src="https://img.shields.io/npm/v/dsh-openrouter-spend?style=flat-square" alt="npm version"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/dsh-openrouter-spend?style=flat-square" alt="MIT license"></a>
16
+ <img src="https://img.shields.io/badge/DeepSeek%20Harness-plugin-2f6feb?style=flat-square" alt="DeepSeek Harness plugin">
17
+ </p>
18
+
19
+ - A live spend chip under the composer: today's cost, with a popover for the last 7 and 30 days.
20
+ - A Settings page with the daily bar chart, a per-model table, per-API-key totals, and the remaining prepaid balance.
21
+ - One cached poll per refresh window: several open tabs cost one request to OpenRouter.
22
+
23
+ ## Requirements
24
+
25
+ - DeepSeek Harness with the web GUI (the plugin is inactive in headless profiles: it needs the `credentials`, `webServer`, and `connection` services).
26
+ - An OpenRouter **management API key** — Settings → API keys → Management in the OpenRouter dashboard. A regular inference key cannot read analytics.
27
+
28
+ ## Install
29
+
30
+ From npm:
31
+
32
+ ```sh
33
+ dsh plugin --profile demo add dsh-openrouter-spend
34
+ ```
35
+
36
+ From GitHub (no build step, so no build-script permission is needed):
37
+
38
+ ```sh
39
+ dsh plugin --profile demo add github:RyabykinIlya/dsh-openrouter-spend#v0.1.0
40
+ ```
41
+
42
+ Then open Settings → OpenRouter spend in the GUI and store the management key. Done — the chip starts filling in.
43
+
44
+ ## Configuration
45
+
46
+ Every field can be overridden in the profile's `cordis.patch.yml` row; the schema validates the row at activation and names the offending field.
47
+
48
+ | Field | Default | Bounds | Meaning |
49
+ | --- | --- | --- | --- |
50
+ | `credentialRef` | `OPENROUTER_MGMT_API_KEY` | non-empty string | Credentials reference holding the management key |
51
+ | `apiBase` | `https://openrouter.ai/api/v1` | `https://` URL | OpenRouter REST API root |
52
+ | `refreshSeconds` | `60` | 10–3600, integer | Seconds between OpenRouter refreshes |
53
+ | `historyDays` | `30` | 2–366, integer | Days of history the summary window covers, today included |
54
+ | `timeoutMs` | `15000` | 1000–120000, integer | Per-request deadline against OpenRouter |
55
+
56
+ Viewing preferences (per-key filter, refresh interval override) are browser-local and never leave your machine.
57
+
58
+ ## How it works
59
+
60
+ The host half queries `POST /api/v1/analytics/query` (`total_usage` and `request_count`, split by API key and model, per UTC day) and `GET /api/v1/credits`, caches the summary for `refreshSeconds`, and serves it to the browser half. When a refresh fails, the last good figures stay on screen and the reason is shown instead of an empty panel.
61
+
62
+ ## Security
63
+
64
+ - The management key is stored through the Harness credentials service (`credentials.set`), not in this package's files, and is never echoed back to the browser.
65
+ - The summary and credential routes are fenced by the connection trust check. Anyone who can pass that check — by default, the browser session on your local Harness port — can read your spend figures. Do not expose the Harness web port publicly without its own authentication.
66
+
67
+ ## License
68
+
69
+ [MIT](./LICENSE). Unofficial plugin; not affiliated with OpenRouter or DeepSeek.
70
+
71
+ ## По-русски
72
+
73
+ Плагин для DeepSeek Harness: под полем ввода показывает реальные расходы аккаунта OpenRouter за сегодня (данные из официальной аналитики OpenRouter), а в настройках — график по дням, разбивку по моделям и API-ключам и остаток предоплаченных кредитов. Установка: `dsh plugin --profile demo add dsh-openrouter-spend`, затем Settings → OpenRouter spend → ввести управляющий ключ (Management API key из кабинета OpenRouter).
package/client.js ADDED
@@ -0,0 +1,480 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-openrouter-spend',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const { useCallback, useEffect, useMemo, useRef, useState } = React;
7
+
8
+ /** Dictionary namespace declared on the registrations, so `t` reaches the components. */
9
+ const NS = 'openrouter-spend';
10
+
11
+ /** Preferences are viewing choices: a browser-local store, not host state. */
12
+ const PREF_KEY_ID = 'ors.keyId';
13
+ const PREF_INTERVAL = 'ors.intervalSeconds';
14
+ const PREFS_CHANGED = 'ors:prefs';
15
+
16
+ const CSS = `
17
+ .ors-root { position: relative; display: inline-flex; }
18
+ .ors-chip {
19
+ display: inline-flex; align-items: center; gap: 6px;
20
+ padding: 3px 10px; border: 0; border-radius: 999px; cursor: pointer;
21
+ background: var(--dsw-alias-interactive-bg-hover, transparent);
22
+ color: var(--dsw-alias-label-secondary);
23
+ font: var(--dsw-font-xxs-12, 12px/1.4 system-ui);
24
+ }
25
+ .ors-chip:hover { background: var(--dsw-alias-interactive-bg-hover-solid, var(--dsw-alias-interactive-bg-hover)); }
26
+ .ors-chip[aria-expanded="true"] { color: var(--dsw-alias-label-primary); }
27
+ .ors-chip-amount { color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
28
+ .ors-chip-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-business-primary); }
29
+ .ors-chip-dot[data-state="stale"] { background: var(--dsw-alias-state-warn-primary, #c08b00); }
30
+ .ors-chip-dot[data-state="loading"] { background: var(--dsw-alias-label-tertiary); }
31
+ .ors-chip-dot[data-state="error"], .ors-chip-dot[data-state="no-credential"] { background: var(--dsw-alias-state-error-primary); }
32
+ .ors-panel {
33
+ position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 40; width: 340px;
34
+ padding: 12px; border-radius: 12px; text-align: left;
35
+ background: var(--dsw-alias-bg-layer-1, var(--dsw-alias-bg-base));
36
+ border: 0.5px solid var(--dsw-alias-border-l2);
37
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
38
+ color: var(--dsw-alias-label-primary);
39
+ font: var(--dsw-font-xs-13, 13px/1.45 system-ui);
40
+ }
41
+ .ors-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
42
+ .ors-label { color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui); }
43
+ .ors-figure { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
44
+ .ors-sub { margin-top: 2px; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui); }
45
+ .ors-range, .ors-keyrow { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0 8px; }
46
+ .ors-range button, .ors-key {
47
+ padding: 3px 8px; border-radius: 6px; cursor: pointer; border: 0;
48
+ background: var(--dsw-alias-interactive-bg-hover, transparent);
49
+ color: var(--dsw-alias-label-secondary);
50
+ font: var(--dsw-font-xxs-12, 12px/1.4 system-ui);
51
+ }
52
+ .ors-range button[aria-pressed="true"], .ors-key[aria-pressed="true"] {
53
+ background: var(--dsw-alias-interactive-bg-hover-solid, var(--dsw-alias-interactive-bg-hover));
54
+ color: var(--dsw-alias-label-primary);
55
+ }
56
+ .ors-table { width: 100%; border-collapse: collapse; }
57
+ .ors-table th {
58
+ padding: 4px 0; text-align: left; color: var(--dsw-alias-label-tertiary);
59
+ font: var(--dsw-font-xxs-12, 12px/1.4 system-ui); font-weight: 400;
60
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
61
+ }
62
+ .ors-table th:last-child, .ors-table td:last-child { text-align: right; }
63
+ .ors-table td { padding: 4px 0; border-bottom: 0.5px solid var(--dsw-alias-separator-primary); vertical-align: top; }
64
+ .ors-table td:first-child { padding-right: 8px; word-break: break-word; }
65
+ .ors-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
66
+ .ors-bars { display: flex; align-items: flex-end; gap: 1px; height: 34px; margin-top: 10px; }
67
+ .ors-bar { flex: 1 1 0; min-height: 1px; border-radius: 1px; background: var(--dsw-alias-state-business-primary); opacity: 0.65; }
68
+ .ors-bar[data-today="true"] { opacity: 1; }
69
+ .ors-note { margin-top: 8px; color: var(--dsw-alias-state-warn-primary, #c08b00); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui); }
70
+ .ors-settings { display: flex; flex-direction: column; gap: 14px; padding: 4px 0; }
71
+ .ors-field { display: flex; flex-direction: column; gap: 4px; }
72
+ .ors-field input, .ors-field select {
73
+ padding: 6px 8px; border-radius: 8px;
74
+ border: 0.5px solid var(--dsw-alias-border-l2);
75
+ background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary);
76
+ font: var(--dsw-font-xs-13, 13px/1.45 system-ui);
77
+ }
78
+ .ors-actions { display: flex; gap: 6px; align-items: center; }
79
+ .ors-actions button {
80
+ padding: 5px 10px; border-radius: 8px; cursor: pointer; border: 0;
81
+ background: var(--dsw-alias-interactive-bg-hover, transparent);
82
+ color: var(--dsw-alias-label-primary); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui);
83
+ }
84
+ `;
85
+
86
+ /** Money with the precision a sub-cent figure needs, and none it does not. */
87
+ function fmtUsd(value) {
88
+ 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)}`;
93
+ }
94
+
95
+ const fmtInt = value => Number(value || 0).toLocaleString('en-US');
96
+
97
+ /** A stable color per API key, so one key keeps its hue across reloads. */
98
+ function keyTint(id) {
99
+ let hash = 0;
100
+ for (const char of String(id)) hash = (hash * 31 + char.codePointAt(0)) % 360;
101
+ return `hsl(${hash} 62% 55%)`;
102
+ }
103
+
104
+ /** Read one viewing preference; private mode reads as the default. */
105
+ function readPref(name, fallback) {
106
+ try {
107
+ const stored = window.localStorage.getItem(name);
108
+ return stored === null ? fallback : stored;
109
+ } catch {
110
+ return fallback;
111
+ }
112
+ }
113
+
114
+ /** Store one viewing preference and tell the other component about it. */
115
+ function writePref(name, value) {
116
+ try {
117
+ window.localStorage.setItem(name, value);
118
+ } catch {
119
+ // Private mode keeps the session usable; the choice simply does not persist.
120
+ }
121
+ window.dispatchEvent(new CustomEvent(PREFS_CHANGED));
122
+ }
123
+
124
+ /** One shared fetch of the host summary. */
125
+ function fetchSummary() {
126
+ return fetch('/openrouter-spend/summary', {
127
+ credentials: 'same-origin',
128
+ headers: { accept: 'application/json' },
129
+ }).then(response => (response.ok ? response.json() : undefined)).catch(() => undefined);
130
+ }
131
+
132
+ function Pill(props) {
133
+ const { t } = props;
134
+ const [data, setData] = useState(null);
135
+ const [open, setOpen] = useState(false);
136
+ const [range, setRange] = useState('today');
137
+ const [keyId, setKeyId] = useState(() => readPref(PREF_KEY_ID, ''));
138
+ const [intervalSeconds, setIntervalSeconds] = useState(() => Number(readPref(PREF_INTERVAL, '0')));
139
+ const rootRef = useRef(null);
140
+
141
+ useEffect(() => { void fetchSummary().then(reply => { if (reply !== undefined) setData(reply); }); }, []);
142
+
143
+ const seconds = intervalSeconds > 0 ? intervalSeconds : Number(data?.refreshSeconds) || 60;
144
+ useEffect(() => {
145
+ const timer = setInterval(() => {
146
+ void fetchSummary().then(reply => { if (reply !== undefined) setData(reply); });
147
+ }, seconds * 1000);
148
+ return () => clearInterval(timer);
149
+ }, [seconds]);
150
+
151
+ // The Settings section writes the same preferences; follow it live.
152
+ useEffect(() => {
153
+ const sync = () => {
154
+ setKeyId(readPref(PREF_KEY_ID, ''));
155
+ setIntervalSeconds(Number(readPref(PREF_INTERVAL, '0')));
156
+ };
157
+ window.addEventListener(PREFS_CHANGED, sync);
158
+ return () => window.removeEventListener(PREFS_CHANGED, sync);
159
+ }, []);
160
+
161
+ useEffect(() => {
162
+ if (!open) return undefined;
163
+ const onPointerDown = event => {
164
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
165
+ };
166
+ const onKeyDown = event => { if (event.key === 'Escape') setOpen(false); };
167
+ document.addEventListener('mousedown', onPointerDown);
168
+ document.addEventListener('keydown', onKeyDown);
169
+ return () => {
170
+ document.removeEventListener('mousedown', onPointerDown);
171
+ document.removeEventListener('keydown', onKeyDown);
172
+ };
173
+ }, [open]);
174
+
175
+ const chooseKey = useCallback(id => {
176
+ setKeyId(id);
177
+ writePref(PREF_KEY_ID, id);
178
+ }, []);
179
+
180
+ const keys = data?.byKey ?? [];
181
+ const selected = keys.find(entry => entry.id === keyId);
182
+
183
+ const view = useMemo(() => {
184
+ if (selected !== undefined) {
185
+ if (range === 'today') return { usd: selected.todayUsd, requests: selected.todayRequests };
186
+ if (range === 'last7') return selected.last7;
187
+ return selected.last30;
188
+ }
189
+ if (range === 'today') return data?.todaySpend ?? { usd: 0, requests: 0 };
190
+ if (range === 'last7') return data?.last7 ?? { usd: 0, requests: 0 };
191
+ return data?.last30 ?? { usd: 0, requests: 0 };
192
+ }, [data, selected, range]);
193
+
194
+ const rows = selected !== undefined ? selected.todayModels : (data?.byModel ?? []);
195
+ const bars = data?.byDay ?? [];
196
+ const peak = bars.reduce((max, entry) => Math.max(max, entry.usd), 0);
197
+ const balance = data?.credits ? data.credits.totalCredits - data.credits.totalUsage : undefined;
198
+ const rangeLabel = range === 'today' ? t('today') : range === 'last7' ? t('last7') : t('last30');
199
+
200
+ if (data !== null && data.status === 'no-credential') {
201
+ return h('span', { className: 'ors-root' },
202
+ h('style', null, CSS),
203
+ h('span', { className: 'ors-chip', title: t('noCredential') },
204
+ h('span', { className: 'ors-chip-dot', 'data-state': 'no-credential' }),
205
+ t('noCredential')));
206
+ }
207
+
208
+ return h('div', { className: 'ors-root', ref: rootRef },
209
+ h('style', null, CSS),
210
+ h('button', {
211
+ type: 'button',
212
+ className: 'ors-chip',
213
+ 'aria-expanded': open,
214
+ 'aria-label': t('open'),
215
+ title: t('open'),
216
+ onClick: () => setOpen(value => !value),
217
+ },
218
+ 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))),
220
+ open && h('div', { className: 'ors-panel' },
221
+ h('div', { className: 'ors-head' },
222
+ h('span', { className: 'ors-label' }, rangeLabel),
223
+ h('span', { className: 'ors-figure' }, fmtUsd(view.usd))),
224
+ h('div', { className: 'ors-sub' },
225
+ `${fmtInt(view.requests)} ${t('requests')}`
226
+ + (data?.refreshedAt === undefined
227
+ ? ''
228
+ : ` · ${t('updated')} ${new Date(data.refreshedAt).toLocaleTimeString()}`)),
229
+ h('div', { className: 'ors-range' },
230
+ ['today', 'last7', 'last30'].map(id => h('button', {
231
+ key: id,
232
+ type: 'button',
233
+ 'aria-pressed': range === id,
234
+ onClick: () => setRange(id),
235
+ }, id === 'today' ? t('today') : id === 'last7' ? t('last7') : t('last30')))),
236
+ balance === undefined ? null : h('div', { className: 'ors-sub' },
237
+ `${t('balance')}: ${fmtUsd(balance)} · ${t('lifetime')}: ${fmtUsd(data.credits.totalUsage)}`),
238
+ keys.length > 0 && h('div', null,
239
+ h('div', { className: 'ors-sub' }, t('byKey')),
240
+ h('div', { className: 'ors-keyrow' },
241
+ [null, ...keys.map(entry => entry.id)].map(id => h('button', {
242
+ key: id ?? '__all__',
243
+ type: 'button',
244
+ className: 'ors-key',
245
+ 'aria-pressed': keyId === (id ?? ''),
246
+ style: id === null ? undefined : { color: keyTint(id) },
247
+ onClick: () => chooseKey(id ?? ''),
248
+ }, id === null ? t('allKeys') : id))),
249
+ h('div', { className: 'ors-sub' }, t('byModel'))),
250
+ h('table', { className: 'ors-table' },
251
+ h('thead', null, h('tr', null,
252
+ h('th', null, t('byModel')),
253
+ h('th', null, '$'),
254
+ h('th', null, t('requests')))),
255
+ h('tbody', null, rows.length === 0
256
+ ? h('tr', null, h('td', { colSpan: 3, className: 'ors-label' }, '—'))
257
+ : rows.map(entry => h('tr', { key: entry.id },
258
+ h('td', null, entry.id),
259
+ h('td', { className: 'ors-num' }, fmtUsd(entry.usd)),
260
+ h('td', { className: 'ors-num' }, fmtInt(entry.requests)))))),
261
+ bars.length > 0 && peak > 0 && h('div', { className: 'ors-bars' },
262
+ bars.map(entry => h('div', {
263
+ key: entry.date,
264
+ className: 'ors-bar',
265
+ 'data-today': entry.date === data.today,
266
+ style: { height: `${Math.max(2, Math.round((entry.usd / peak) * 34))}px` },
267
+ title: `${entry.date} · ${fmtUsd(entry.usd)}`,
268
+ }))),
269
+ data?.error === undefined ? null : h('div', { className: 'ors-note' }, data.error)));
270
+ }
271
+
272
+ function Settings(props) {
273
+ const { t } = props;
274
+ const [data, setData] = useState(null);
275
+ const [keyId, setKeyId] = useState(() => readPref(PREF_KEY_ID, ''));
276
+ const [intervalSeconds, setIntervalSeconds] = useState(() => readPref(PREF_INTERVAL, '0'));
277
+ const [draft, setDraft] = useState('');
278
+ const [notice, setNotice] = useState(null);
279
+
280
+ const reload = useCallback(async () => {
281
+ const reply = await fetchSummary();
282
+ if (reply !== undefined) setData(reply);
283
+ }, []);
284
+
285
+ useEffect(() => { void reload(); }, [reload]);
286
+
287
+ const submit = useCallback(async body => {
288
+ setNotice(null);
289
+ try {
290
+ const response = await fetch('/openrouter-spend/credential', {
291
+ method: 'POST',
292
+ credentials: 'same-origin',
293
+ headers: { 'content-type': 'application/json' },
294
+ body: JSON.stringify(body),
295
+ });
296
+ const reply = await response.json();
297
+ if (!response.ok) {
298
+ setNotice(reply?.error ?? `${response.status}`);
299
+ return;
300
+ }
301
+ setData(reply);
302
+ setDraft('');
303
+ setNotice(t('saved'));
304
+ } catch (error) {
305
+ setNotice(error instanceof Error ? error.message : String(error));
306
+ }
307
+ }, [t]);
308
+
309
+ const credential = data?.credential;
310
+ const keys = data?.byKey ?? [];
311
+ const selectKey = value => {
312
+ setKeyId(value);
313
+ writePref(PREF_KEY_ID, value);
314
+ };
315
+ const selectInterval = value => {
316
+ setIntervalSeconds(value);
317
+ writePref(PREF_INTERVAL, value);
318
+ };
319
+
320
+ return h('div', { className: 'ors-settings' },
321
+ h('style', null, CSS),
322
+ h('div', null,
323
+ h('div', { className: 'ors-head' },
324
+ h('span', { className: 'ors-label' }, t('today')),
325
+ h('span', { className: 'ors-figure' },
326
+ fmtUsd(data?.todaySpend?.usd ?? 0))),
327
+ h('div', { className: 'ors-sub' },
328
+ `${fmtInt(data?.todaySpend?.requests ?? 0)} ${t('requests')}`
329
+ + (data?.credits === undefined
330
+ ? ''
331
+ : ` · ${t('balance')}: ${fmtUsd(data.credits.totalCredits - data.credits.totalUsage)}`)),
332
+ data?.error === undefined ? null : h('div', { className: 'ors-note' }, data.error)),
333
+ h('div', { className: 'ors-field' },
334
+ h('label', { className: 'ors-sub' }, t('countByKey')),
335
+ h('select', { value: keyId, onChange: event => selectKey(event.target.value) },
336
+ h('option', { value: '' }, t('allKeys')),
337
+ keys.map(entry => h('option', { key: entry.id, value: entry.id },
338
+ `${entry.id} · ${fmtUsd(entry.todayUsd)} ${t('today')}`)),
339
+ keyId !== '' && keys.every(entry => entry.id !== keyId)
340
+ ? h('option', { value: keyId }, `${keyId} · ${t('unknownKey')}`)
341
+ : null)),
342
+ h('div', { className: 'ors-field' },
343
+ h('label', { className: 'ors-sub' }, t('refreshEvery')),
344
+ h('select', { value: intervalSeconds, onChange: event => selectInterval(event.target.value) },
345
+ h('option', { value: '0' }, `${t('serverDefault')} (${data?.refreshSeconds ?? 60}s)`),
346
+ ['30', '60', '300', '900'].map(value => h('option', { key: value, value }, `${value}s`)))),
347
+ h('div', { className: 'ors-field' },
348
+ h('label', { className: 'ors-sub' }, t('managementKey')),
349
+ h('input', {
350
+ type: 'password',
351
+ value: draft,
352
+ placeholder: credential?.configured === true ? '••••••••' : '',
353
+ autoComplete: 'off',
354
+ spellCheck: false,
355
+ onChange: event => setDraft(event.target.value),
356
+ }),
357
+ credential?.configured === true
358
+ ? h('div', { className: 'ors-sub' },
359
+ `${t('keyStored')} (${credential.ref}${credential.source === undefined ? '' : ` · ${credential.source}`})`)
360
+ : h('div', { className: 'ors-sub' }, t('keyHint')),
361
+ h('div', { className: 'ors-actions' },
362
+ h('button', {
363
+ type: 'button',
364
+ disabled: draft.length === 0,
365
+ onClick: () => { void submit({ value: draft }); },
366
+ }, t('save')),
367
+ credential?.configured === true && h('button', {
368
+ type: 'button',
369
+ onClick: () => { void submit({ clear: true }); },
370
+ }, t('clear')),
371
+ h('button', { type: 'button', onClick: () => { void reload(); } }, t('reload'))),
372
+ credential?.writable === false
373
+ ? h('div', { className: 'ors-note' }, t('keyReadOnly'))
374
+ : null,
375
+ notice === null ? null : h('div', { className: 'ors-sub' }, notice)));
376
+ }
377
+
378
+ return {
379
+ inject: ['slots', 'locale'],
380
+ apply(ctx) {
381
+ // The nav label is read through a thunk so it follows the active locale.
382
+ let tr = key => key;
383
+ ctx.effect(() => {
384
+ const dispose = ctx.locale.register(NS, {
385
+ en: {
386
+ today: 'Today',
387
+ last7: '7 days',
388
+ last30: '30 days',
389
+ requests: 'requests',
390
+ balance: 'Balance',
391
+ lifetime: 'Lifetime',
392
+ byModel: 'Model',
393
+ byKey: 'API key',
394
+ allKeys: 'All keys',
395
+ unknownKey: 'not seen today',
396
+ noCredential: 'No management key',
397
+ loading: 'Loading…',
398
+ updated: 'Updated',
399
+ open: 'OpenRouter spend',
400
+ countByKey: 'Count only this API key',
401
+ refreshEvery: 'Refresh every',
402
+ serverDefault: 'Plugin default',
403
+ managementKey: 'Management API key',
404
+ keyStored: 'Stored as',
405
+ keyHint: 'Needs an OpenRouter management key: Settings → API keys → Management.',
406
+ keyReadOnly: 'A read-only source shadows this reference, so Settings cannot store it.',
407
+ save: 'Save',
408
+ clear: 'Clear',
409
+ reload: 'Reload',
410
+ saved: 'Saved',
411
+ },
412
+ zh: {
413
+ today: '今天',
414
+ last7: '7 天',
415
+ last30: '30 天',
416
+ requests: '请求',
417
+ balance: '余额',
418
+ lifetime: '累计',
419
+ byModel: '模型',
420
+ byKey: 'API 密钥',
421
+ allKeys: '全部密钥',
422
+ unknownKey: '今天未出现',
423
+ noCredential: '未配置管理密钥',
424
+ loading: '读取中…',
425
+ updated: '更新于',
426
+ open: 'OpenRouter 花费',
427
+ countByKey: '只统计这个 API 密钥',
428
+ refreshEvery: '刷新间隔',
429
+ serverDefault: '插件默认',
430
+ managementKey: '管理密钥',
431
+ keyStored: '已保存为',
432
+ keyHint: '需要 OpenRouter 管理密钥:Settings → API keys → Management。',
433
+ keyReadOnly: '该引用被只读来源遮蔽,无法在设置中保存。',
434
+ save: '保存',
435
+ clear: '清除',
436
+ reload: '刷新',
437
+ saved: '已保存',
438
+ },
439
+ ru: {
440
+ today: 'Сегодня',
441
+ last7: '7 дней',
442
+ last30: '30 дней',
443
+ requests: 'запросов',
444
+ balance: 'Баланс',
445
+ lifetime: 'Всего',
446
+ byModel: 'Модель',
447
+ byKey: 'API-ключ',
448
+ allKeys: 'Все ключи',
449
+ unknownKey: 'сегодня не было',
450
+ noCredential: 'Ключ не настроен',
451
+ loading: 'Загрузка…',
452
+ updated: 'Обновлено',
453
+ open: 'Расходы OpenRouter',
454
+ countByKey: 'Учитывать только этот API-ключ',
455
+ refreshEvery: 'Обновлять каждые',
456
+ serverDefault: 'По умолчанию',
457
+ managementKey: 'Управляющий API-ключ',
458
+ keyStored: 'Сохранён как',
459
+ keyHint: 'Нужен управляющий ключ OpenRouter: Settings → API keys → Management.',
460
+ keyReadOnly: 'Эта ссылка перекрыта источником только для чтения, настройки не могут её сохранить.',
461
+ save: 'Сохранить',
462
+ clear: 'Очистить',
463
+ reload: 'Обновить',
464
+ saved: 'Сохранено',
465
+ },
466
+ });
467
+ tr = ctx.locale.bind(NS);
468
+ return dispose;
469
+ });
470
+ const section = { id: 'openrouter-spend', order: 30, label: () => tr('open'), locale: NS };
471
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
472
+ name: 'conversation.composer.dock', id: 'openrouter-spend', order: 2, label: section.label, locale: NS,
473
+ }, Pill));
474
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
475
+ name: 'settings.section', ...section,
476
+ }, Settings));
477
+ },
478
+ };
479
+ },
480
+ });
@@ -0,0 +1,7 @@
1
+ - insert:
2
+ - id: openrouter-spend
3
+ name: 'dsh-openrouter-spend'
4
+ config:
5
+ credentialRef: OPENROUTER_MGMT_API_KEY
6
+ refreshSeconds: 60
7
+ historyDays: 30
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <circle cx="32" cy="32" r="26" fill="none" stroke="#2f6feb" stroke-width="5" stroke-dasharray="122 41" stroke-linecap="round" transform="rotate(-90 32 32)"/>
3
+ <path d="M32 15v34M38 23.5c0-3.3-2.7-5.5-6-5.5s-6 2-6 5c0 7.5 12 4.5 12 12 0 3.5-2.8 6-6 6s-6-2.2-6-5.5" fill="none" stroke="#2f6feb" stroke-width="4" stroke-linecap="round"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Host half of the OpenRouter spend bundle.
3
+ *
4
+ * Reads what the OpenRouter account actually charged with a management key and
5
+ * serves it to the browser half as one cached summary. Every figure comes from
6
+ * OpenRouter's own analytics (`total_usage`), so it is billed money rather than
7
+ * a token estimate priced from a local table.
8
+ */
9
+
10
+ import Schema from 'schemastery'
11
+
12
+ /** Loader row id; matches the `insert` in cordis.patch.yml. */
13
+ export const name = 'openrouter-spend'
14
+
15
+ /** `credentials` resolves the management key, `webServer` carries the route. */
16
+ export const inject = ['credentials', 'webServer', 'connection']
17
+
18
+ /** One read-only summary route, fenced by the connection trust check. */
19
+ const SUMMARY_PATH = '/openrouter-spend/summary'
20
+
21
+ /** Route that stores or clears the management key. */
22
+ const CREDENTIAL_PATH = '/openrouter-spend/credential'
23
+
24
+ /** A credential body is one string; a larger one is hostile, not a real setting. */
25
+ const MAX_BODY_BYTES = 64 * 1024
26
+
27
+ /** A runaway guard: the window holds one row per day, key, and model. */
28
+ const MAX_REPLY_BYTES = 4 * 1024 * 1024
29
+
30
+ /** Rows beyond this are refused rather than truncated: a partial split misreports cost. */
31
+ const MAX_ANALYTICS_ROWS = 500
32
+
33
+ /**
34
+ * The row's `config` schema: validated by the Loader before `apply`, so a
35
+ * wrong value fails the row at activation instead of misreporting spend later.
36
+ * Every field is overridable from the patch row so a deployment can retarget
37
+ * the API, the credential, or the cadence without an edit to this file.
38
+ */
39
+ export const Config = Schema.object({
40
+ credentialRef: Schema.string().default('OPENROUTER_MGMT_API_KEY')
41
+ .description('Credentials reference holding the OpenRouter management API key.'),
42
+ apiBase: Schema.string().pattern(/^https:\/\//).default('https://openrouter.ai/api/v1')
43
+ .description('OpenRouter REST API root; https only.'),
44
+ refreshSeconds: Schema.number().step(1).min(10).max(3600).default(60)
45
+ .description('Seconds between OpenRouter refreshes; one poll serves every open tab.'),
46
+ historyDays: Schema.number().step(1).min(2).max(366).default(30)
47
+ .description('Days of history the summary window covers, today included.'),
48
+ timeoutMs: Schema.number().step(1).min(1000).max(120_000).default(15_000)
49
+ .description('Per-request deadline against the OpenRouter API, in milliseconds.'),
50
+ })
51
+
52
+ /**
53
+ * Read one analytics row. `request_count` arrives as a decimal string and the
54
+ * dimensions are strings, so this is the wire boundary the narrowing belongs at.
55
+ * @returns the row, or `undefined` when it does not carry a usable day and cost.
56
+ */
57
+ function readRow(row) {
58
+ if (row === null || typeof row !== 'object') return undefined
59
+ const day = row.date__day
60
+ const key = row.api_key_id
61
+ const model = row.model
62
+ const usd = Number(row.total_usage)
63
+ const requests = Number(row.request_count)
64
+ if (typeof day !== 'string' || typeof key !== 'string' || typeof model !== 'string') return undefined
65
+ if (!Number.isFinite(usd) || !Number.isFinite(requests)) return undefined
66
+ return { day, key, model, usd, requests }
67
+ }
68
+
69
+ /** Fetch the window split by API key and model; one query answers both views. */
70
+ async function queryAnalytics(config, credential, start, end, signal) {
71
+ const url = `${config.apiBase.replace(/\/+$/, '')}/analytics/query`
72
+ const response = await fetch(url, {
73
+ method: 'POST',
74
+ headers: { authorization: `Bearer ${credential}`, 'content-type': 'application/json' },
75
+ body: JSON.stringify({
76
+ metrics: ['total_usage', 'request_count'],
77
+ dimensions: ['api_key_id', 'model'],
78
+ granularity: 'day',
79
+ time_range: { start, end },
80
+ limit: MAX_ANALYTICS_ROWS,
81
+ }),
82
+ signal,
83
+ })
84
+ if (!response.ok) {
85
+ const detail = (await response.text()).slice(0, 200)
86
+ throw new Error(`analytics/query answered ${response.status}: ${detail}`)
87
+ }
88
+ const length = Number(response.headers.get('content-length') ?? 0)
89
+ if (Number.isFinite(length) && length > MAX_REPLY_BYTES) {
90
+ throw new Error(`analytics/query reply exceeds ${MAX_REPLY_BYTES} bytes`)
91
+ }
92
+ const body = await response.json()
93
+ const rows = body?.data?.data
94
+ if (!Array.isArray(rows)) throw new Error('analytics/query returned no data rows')
95
+ if (rows.length >= MAX_ANALYTICS_ROWS) {
96
+ throw new Error(`analytics/query hit its ${MAX_ANALYTICS_ROWS}-row ceiling; narrow config.historyDays`)
97
+ }
98
+ return rows.flatMap(row => {
99
+ const read = readRow(row)
100
+ return read === undefined ? [] : [read]
101
+ })
102
+ }
103
+
104
+ /** Read the prepaid credits balance, which is the account's remaining budget. */
105
+ async function queryCredits(config, credential, signal) {
106
+ const url = `${config.apiBase.replace(/\/+$/, '')}/credits`
107
+ const response = await fetch(url, { headers: { authorization: `Bearer ${credential}` }, signal })
108
+ if (!response.ok) throw new Error(`credits answered ${response.status}`)
109
+ const body = await response.json()
110
+ const data = body?.data
111
+ if (typeof data?.total_credits !== 'number' || typeof data?.total_usage !== 'number') {
112
+ throw new Error('credits returned no total_credits/total_usage')
113
+ }
114
+ return { totalCredits: data.total_credits, totalUsage: data.total_usage }
115
+ }
116
+
117
+ /** UTC calendar day of a timestamp, the bucket OpenRouter answers `date__day` with. */
118
+ function utcDay(now) {
119
+ return now.toISOString().slice(0, 10)
120
+ }
121
+
122
+ /** UTC midnight opening the day that is `offset` days before `now`'s day. */
123
+ function startOfUtcDay(now, offset) {
124
+ const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - offset))
125
+ return `${day.toISOString().slice(0, 10)}T00:00:00Z`
126
+ }
127
+
128
+ /** Add one row's figures into an accumulator, creating it when the first row lands. */
129
+ function add(target, id, row) {
130
+ const entry = target.get(id) ?? { usd: 0, requests: 0 }
131
+ entry.usd += row.usd
132
+ entry.requests += row.requests
133
+ target.set(id, entry)
134
+ }
135
+
136
+ /** Zero-filled daily totals across the window, so a chart has no missing bars. */
137
+ function dailySeries(rows, historyDays, now) {
138
+ const totals = new Map()
139
+ for (const row of rows) add(totals, row.day, row)
140
+ const series = []
141
+ for (let offset = historyDays - 1; offset >= 0; offset -= 1) {
142
+ const date = utcDay(new Date(now.getTime() - offset * 86_400_000))
143
+ const entry = totals.get(date) ?? { usd: 0, requests: 0 }
144
+ series.push({ date, usd: entry.usd, requests: entry.requests })
145
+ }
146
+ return series
147
+ }
148
+
149
+ /** Sum the trailing `days` entries of a daily series, today included. */
150
+ function trailing(series, days) {
151
+ return series.slice(-days).reduce((sum, entry) => ({
152
+ usd: sum.usd + entry.usd,
153
+ requests: sum.requests + entry.requests,
154
+ }), { usd: 0, requests: 0 })
155
+ }
156
+
157
+ /**
158
+ * Per-key totals over the window plus today's per-key model split, so selecting
159
+ * a key in the UI answers both "how much" and "on what" without a second poll.
160
+ */
161
+ function byKey(rows, today, series) {
162
+ const totals = new Map()
163
+ const todayModels = new Map()
164
+ const perDay = new Map()
165
+ for (const row of rows) {
166
+ add(totals, row.key, row)
167
+ const days = perDay.get(row.key) ?? new Map()
168
+ add(days, row.day, row)
169
+ perDay.set(row.key, days)
170
+ if (row.day !== today) continue
171
+ const models = todayModels.get(row.key) ?? new Map()
172
+ add(models, row.model, row)
173
+ todayModels.set(row.key, models)
174
+ }
175
+ const trailingFor = (key, days) => {
176
+ const window = series.slice(-days).map(entry => entry.date)
177
+ let usd = 0
178
+ let requests = 0
179
+ for (const day of window) {
180
+ const entry = perDay.get(key)?.get(day)
181
+ if (entry === undefined) continue
182
+ usd += entry.usd
183
+ requests += entry.requests
184
+ }
185
+ return { usd, requests }
186
+ }
187
+ return [...totals.entries()]
188
+ .map(([id, entry]) => {
189
+ const models = [...(todayModels.get(id) ?? new Map()).entries()]
190
+ .map(([model, modelEntry]) => ({ id: model, usd: modelEntry.usd, requests: modelEntry.requests }))
191
+ .sort((left, right) => right.usd - left.usd)
192
+ return {
193
+ id,
194
+ usd: entry.usd,
195
+ requests: entry.requests,
196
+ todayModels: models,
197
+ todayUsd: models.reduce((sum, model) => sum + model.usd, 0),
198
+ todayRequests: models.reduce((sum, model) => sum + model.requests, 0),
199
+ last7: trailingFor(id, 7),
200
+ last30: trailingFor(id, 30),
201
+ }
202
+ })
203
+ .sort((left, right) => right.usd - left.usd)
204
+ }
205
+
206
+ /** Today's spend split by model, in descending cost. */
207
+ function byModel(rows, today) {
208
+ const totals = new Map()
209
+ for (const row of rows) {
210
+ if (row.day === today) add(totals, row.model, row)
211
+ }
212
+ return [...totals.entries()]
213
+ .map(([id, entry]) => ({ id, usd: entry.usd, requests: entry.requests }))
214
+ .sort((left, right) => right.usd - left.usd)
215
+ }
216
+
217
+ /**
218
+ * Build the one summary the browser half renders: today's spend, trailing
219
+ * windows, the daily series, per-model and per-key splits, and the balance.
220
+ */
221
+ async function collect(config, credential, signal) {
222
+ const now = new Date()
223
+ const end = new Date(now.getTime() + 60_000).toISOString()
224
+ const start = startOfUtcDay(now, config.historyDays - 1)
225
+ const [rows, credits] = await Promise.all([
226
+ queryAnalytics(config, credential, start, end, signal),
227
+ queryCredits(config, credential, signal),
228
+ ])
229
+ const today = utcDay(now)
230
+ const series = dailySeries(rows, config.historyDays, now)
231
+ return {
232
+ status: 'ok',
233
+ today,
234
+ refreshedAt: now.toISOString(),
235
+ refreshSeconds: config.refreshSeconds,
236
+ historyDays: config.historyDays,
237
+ todaySpend: series[series.length - 1] ?? { date: today, usd: 0, requests: 0 },
238
+ last7: trailing(series, 7),
239
+ last30: trailing(series, 30),
240
+ byDay: series,
241
+ byModel: byModel(rows, today),
242
+ byKey: byKey(rows, today, series),
243
+ credits,
244
+ }
245
+ }
246
+
247
+ /** JSON reply carrying live facts; never cached by the browser. */
248
+ function sendJson(res, status, payload) {
249
+ res.statusCode = status
250
+ res.setHeader('content-type', 'application/json; charset=utf-8')
251
+ res.setHeader('cache-control', 'no-store')
252
+ res.end(JSON.stringify(payload))
253
+ }
254
+
255
+ /** Collect a bounded request body as UTF-8; null past the ceiling (stream drained). */
256
+ async function readBoundedBody(req) {
257
+ const chunks = []
258
+ let size = 0
259
+ for await (const chunk of req) {
260
+ size += chunk.length
261
+ if (size > MAX_BODY_BYTES) {
262
+ // Drain the rest so the refusal is a readable response, not a socket cut.
263
+ req.resume()
264
+ return null
265
+ }
266
+ chunks.push(chunk)
267
+ }
268
+ return Buffer.concat(chunks, size).toString('utf8')
269
+ }
270
+
271
+ /** Validate the credential body at the wire: an object with `value` or `clear`. */
272
+ function parseCredentialBody(text) {
273
+ let body
274
+ try {
275
+ body = JSON.parse(text)
276
+ } catch {
277
+ // A non-JSON body is exactly the refusal case.
278
+ return undefined
279
+ }
280
+ if (body === null || typeof body !== 'object') return undefined
281
+ if (body.clear === true) return { clear: true }
282
+ return typeof body.value === 'string' && body.value.length > 0 ? { value: body.value } : undefined
283
+ }
284
+
285
+ /** The payload the browser shows before any query has answered. */
286
+ function emptyPayload(config) {
287
+ return {
288
+ status: 'loading',
289
+ today: utcDay(new Date()),
290
+ refreshSeconds: config.refreshSeconds,
291
+ historyDays: config.historyDays,
292
+ todaySpend: { usd: 0, requests: 0 },
293
+ last7: { usd: 0, requests: 0 },
294
+ last30: { usd: 0, requests: 0 },
295
+ byDay: [],
296
+ byModel: [],
297
+ byKey: [],
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Register the summary route. The reply is cached for `refreshSeconds` and one
303
+ * refresh serves every concurrent caller, so several open tabs cost one poll.
304
+ */
305
+ export function apply(ctx, config) {
306
+ let cached = undefined
307
+ let inFlight = undefined
308
+
309
+ const summary = async () => {
310
+ const resolved = await ctx.credentials.resolve(config.credentialRef)
311
+ const info = await ctx.credentials.describe(config.credentialRef)
312
+ const credential = {
313
+ ref: config.credentialRef,
314
+ configured: info.configured,
315
+ writable: info.writable,
316
+ ...(info.source === undefined ? {} : { source: info.source }),
317
+ }
318
+ if (resolved === undefined) {
319
+ return { ...emptyPayload(config), status: 'no-credential', credential }
320
+ }
321
+ const signal = AbortSignal.timeout(config.timeoutMs)
322
+ try {
323
+ return { ...(await collect(config, resolved.value, signal)), credential }
324
+ } catch (error) {
325
+ // A stale reply beats an empty panel: keep the last good figures and say why.
326
+ const reason = error instanceof Error ? error.message : String(error)
327
+ ctx.logger.warn('openrouter-spend: refresh failed', reason)
328
+ return {
329
+ ...(cached ?? emptyPayload(config)),
330
+ status: 'stale',
331
+ error: reason,
332
+ credential,
333
+ }
334
+ }
335
+ }
336
+
337
+ const current = () => {
338
+ const fresh = cached !== undefined && Date.now() - cached.fetchedAt < config.refreshSeconds * 1000
339
+ if (fresh) return Promise.resolve(cached.payload)
340
+ if (inFlight !== undefined) return inFlight
341
+ inFlight = (async () => {
342
+ try {
343
+ const payload = await summary()
344
+ cached = { fetchedAt: Date.now(), payload }
345
+ return payload
346
+ } finally {
347
+ inFlight = undefined
348
+ }
349
+ })()
350
+ return inFlight
351
+ }
352
+
353
+ ctx.effect(() => ctx.webServer.register({
354
+ kind: 'exact',
355
+ path: SUMMARY_PATH,
356
+ handler: async (req, res) => {
357
+ const rejection = ctx.connection.requestRejection(req)
358
+ if (rejection !== undefined) {
359
+ res.statusCode = rejection
360
+ res.end()
361
+ return
362
+ }
363
+ if (req.method !== 'GET') {
364
+ res.statusCode = 405
365
+ res.setHeader('allow', 'GET')
366
+ res.end()
367
+ return
368
+ }
369
+ try {
370
+ sendJson(res, 200, await current())
371
+ } catch (error) {
372
+ ctx.logger.warn('openrouter-spend: summary failed', error)
373
+ sendJson(res, 502, { status: 'error', error: error instanceof Error ? error.message : String(error) })
374
+ }
375
+ },
376
+ }), `openrouter-spend: GET ${SUMMARY_PATH}`)
377
+
378
+ // Storing the key: the credentials service owns the write, and its refusal
379
+ // (a shadowing read-only source) is passed through so Settings can show it.
380
+ ctx.effect(() => ctx.webServer.register({
381
+ kind: 'exact',
382
+ path: CREDENTIAL_PATH,
383
+ handler: async (req, res) => {
384
+ const rejection = ctx.connection.requestRejection(req)
385
+ if (rejection !== undefined) {
386
+ res.statusCode = rejection
387
+ res.end()
388
+ return
389
+ }
390
+ if (req.method !== 'POST') {
391
+ res.statusCode = 405
392
+ res.setHeader('allow', 'POST')
393
+ res.end()
394
+ return
395
+ }
396
+ const body = readBoundedBody(req).then(parseCredentialBody)
397
+ const parsed = await body
398
+ if (parsed === undefined) {
399
+ sendJson(res, 400, { status: 'error', error: 'expected {"value": "..."} or {"clear": true}' })
400
+ return
401
+ }
402
+ try {
403
+ if (parsed.clear === true) await ctx.credentials.unset(config.credentialRef)
404
+ else await ctx.credentials.set(config.credentialRef, parsed.value)
405
+ } catch (error) {
406
+ sendJson(res, 409, { status: 'error', error: error instanceof Error ? error.message : String(error) })
407
+ return
408
+ }
409
+ cached = undefined
410
+ sendJson(res, 200, await current())
411
+ },
412
+ }), `openrouter-spend: POST ${CREDENTIAL_PATH}`)
413
+
414
+ // Prime the cache so the first browser poll is a reply rather than a stall.
415
+ ctx.effect(() => {
416
+ void current().catch(error => ctx.logger.warn('openrouter-spend: priming failed', error))
417
+ return () => undefined
418
+ })
419
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenRouter Spend",
4
+ "description": "Shows what the OpenRouter account actually charged today, under the composer."
5
+ }
6
+ }
package/locale/ru.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Расходы OpenRouter",
4
+ "description": "Показывает, сколько аккаунт OpenRouter реально потратил сегодня — под полем ввода."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenRouter 花费",
4
+ "description": "在输入框下方显示 OpenRouter 账户今天实际花费的金额。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "dsh-openrouter-spend",
3
+ "version": "0.1.0",
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
+ "keywords": ["dsh", "dsh-plugin", "cordis", "openrouter", "spend", "cost", "analytics"],
6
+ "license": "MIT",
7
+ "author": "Ilya Ryabykin",
8
+ "type": "module",
9
+ "engines": { "node": ">=22.19" },
10
+ "repository": { "type": "git", "url": "git+https://github.com/RyabykinIlya/dsh-openrouter-spend.git" },
11
+ "homepage": "https://github.com/RyabykinIlya/dsh-openrouter-spend#readme",
12
+ "bugs": { "url": "https://github.com/RyabykinIlya/dsh-openrouter-spend/issues" },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./locale/*.json": "./locale/*.json",
17
+ "./icon": "./icon.svg",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg", "README.md", "LICENSE", "CHANGELOG.md"],
21
+ "publishConfig": { "access": "public" },
22
+ "dependencies": { "schemastery": "^3.18.0" },
23
+ "dsh": {
24
+ "bundle": { "patch": "./cordis.patch.yml" },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": ["@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-settings"]
28
+ }
29
+ }
30
+ }