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 +15 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/client.js +480 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +4 -0
- package/index.js +419 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -0
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
|
+
});
|
package/cordis.patch.yml
ADDED
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
package/locale/ru.json
ADDED
package/locale/zh.json
ADDED
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
|
+
}
|