@hystudio/dsh-quota-pill 0.2.2 → 0.2.3

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/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: '@local/quota-pill',
2
+ id: '@hystudio/dsh-quota-pill',
3
3
  factory(require) {
4
4
  const React = require('react');
5
5
  const ReactDOM = require('react-dom');
@@ -1365,10 +1365,10 @@ window.__ModuleLoader__.load({
1365
1365
  inject: ['slots'],
1366
1366
  apply(ctx) {
1367
1367
  ctx.inject(['slots', 'modelDirectories'], (scope) => {
1368
- const styleId = '@local/quota-pill/order';
1368
+ const styleId = '@hystudio/dsh-quota-pill/order';
1369
1369
  if (!document.querySelector(`style[data-plugin-css="${styleId}"]`)) {
1370
1370
  const tag = document.createElement('style');
1371
- tag.dataset.plugin = '@local/quota-pill';
1371
+ tag.dataset.plugin = '@hystudio/dsh-quota-pill';
1372
1372
  tag.dataset.pluginCss = styleId;
1373
1373
  // Context meter is the last sibling; push the quota ring immediately after it.
1374
1374
  tag.textContent = [
package/index.js CHANGED
@@ -1,8 +1,19 @@
1
1
  /**
2
2
  * Host half of the quota-pill bundle.
3
3
  *
4
- * Rendering lives in client.js (loaded into the DSH web GUI). This half is
5
- * intentionally empty: the plugin only paints a pill, it does not register any
6
- * host-side service or RPC.
4
+ * Rendering lives in client.js, which the web GUI loads separately. This half
5
+ * registers nothing: the plugin only paints a pill, exposes no host service and
6
+ * no RPC.
7
+ *
8
+ * It still has to be a well-formed Cordis plugin, which means exporting `name`.
9
+ * Omitting it makes the loader reject the bundle at import time with
10
+ * "@hystudio/dsh-quota-pill: import failed" — and because the failure happens
11
+ * before activation, nothing in the UI says why. That is how 0.2.2 shipped.
7
12
  */
13
+ export const name = 'quota-pill';
14
+
15
+ /** No host-side services are required. */
16
+ export const inject = [];
17
+
18
+ /** Nothing to set up on the host; the client half does all the work. */
8
19
  export function apply() {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hystudio/dsh-quota-pill",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Quota pill for DeepSeek Harness: remaining quota for every LLM subscription you have, in one collapsible pill",
@@ -19,109 +19,8 @@
19
19
  * value = "v1." + base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, body))
20
20
  * payload = { version: 1, authority, issuedAt, expiresAt } ← milliseconds
21
21
  */
22
- import crypto from 'node:crypto';
23
- import { execFileSync } from 'node:child_process';
24
- import { URL } from 'node:url';
25
- import { deepseekGrant, browserSessionSecret } from './credentials.js';
26
-
27
- const RPC_PORT_GUESSES = [8319, 8080, 3000];
28
- const DSH_BASE_ENV = process.env.DSH_RPC_BASE || process.env.QUOTA_PILL_DSH_BASE || null;
29
-
30
- const b64u = (buf) => Buffer.from(buf).toString('base64url');
31
-
32
- /** Find the DSH web server port. macOS/Linux: ask lsof; otherwise try env then guesses. */
33
- function findDshBase() {
34
- if (DSH_BASE_ENV) return DSH_BASE_ENV.replace(/\/$/, '');
35
- if (process.env.QUOTA_PILL_DSH_PORT) {
36
- return `http://127.0.0.1:${process.env.QUOTA_PILL_DSH_PORT}`;
37
- }
38
- try {
39
- const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
40
- encoding: 'utf8',
41
- timeout: 4000,
42
- });
43
- for (const line of out.split('\n')) {
44
- // The DSH process shows up under its app name; match on the command column.
45
- if (!/DeepSeek|dsh/i.test(line)) continue;
46
- const m = /127\.0\.0\.1:(\d+)/.exec(line);
47
- if (m) return `http://127.0.0.1:${m[1]}`;
48
- }
49
- } catch {
50
- /* lsof missing or blocked — fall through */
51
- }
52
- return null;
53
- }
54
-
55
- /** Build the signed session cookie DSH expects. */
56
- function signCookie(base, secretB64) {
57
- const authority = new URL(base).host;
58
- let raw;
59
- try {
60
- raw = Buffer.from(secretB64, 'base64url');
61
- } catch {
62
- raw = Buffer.from(secretB64, 'utf8');
63
- }
64
- const name = 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest());
65
- const now = Date.now();
66
- const payload = {
67
- version: 1,
68
- authority,
69
- issuedAt: now,
70
- expiresAt: now + 3600 * 1000,
71
- };
72
- const body = b64u(JSON.stringify(payload));
73
- const sig = b64u(crypto.createHmac('sha256', raw).update(body).digest());
74
- return { name, value: `v1.${body}.${sig}` };
75
- }
76
-
77
- /** POST one RPC to DSH and return its result payload. */
78
- async function dshRpc(method, args, base) {
79
- const secret = browserSessionSecret();
80
- if (!secret) return { ok: false, error: 'no browser-session secret in credentials' };
81
-
82
- const { name, value } = signCookie(base, secret);
83
- const body = JSON.stringify({
84
- type: 'client-request',
85
- rpcId: 'quota-pill-' + method,
86
- method,
87
- payload: { args },
88
- });
89
-
90
- const controller = new AbortController();
91
- const timer = setTimeout(() => controller.abort(), 15000);
92
- try {
93
- const res = await fetch(base + '/api/' + method.replace(/\./g, '/'), {
94
- method: 'POST',
95
- headers: {
96
- 'Content-Type': 'application/json',
97
- Accept: 'application/json',
98
- Cookie: `${name}=${value}`,
99
- },
100
- body,
101
- signal: controller.signal,
102
- });
103
- const text = await res.text();
104
- if (!res.ok) {
105
- return { ok: false, status: res.status, error: `dsh rpc http ${res.status}: ${text.slice(0, 120)}` };
106
- }
107
- let env;
108
- try {
109
- env = JSON.parse(text);
110
- } catch {
111
- return { ok: false, error: 'dsh rpc returned unparsable json' };
112
- }
113
- const result = (env && env.result) || {};
114
- if (result.ok === false) {
115
- const e = result.error || {};
116
- return { ok: false, error: `${e.code || 'rpc-failed'}: ${e.message || ''}`.trim() };
117
- }
118
- return { ok: true, value: result.value };
119
- } catch (e) {
120
- return { ok: false, error: String((e && e.message) || e) };
121
- } finally {
122
- clearTimeout(timer);
123
- }
124
- }
22
+ import { deepseekGrant } from './credentials.js';
23
+ import { dshRpc } from './dsh.js';
125
24
 
126
25
  /**
127
26
  * Official DeepSeek balance.
@@ -142,21 +41,11 @@ export async function deepseek() {
142
41
  };
143
42
  }
144
43
 
145
- const base = findDshBase();
146
- if (!base) {
147
- return {
148
- kind: 'none',
149
- provider: 'deepseek',
150
- error: 'cannot locate the DSH web server (needed to read Platform balance)',
151
- setup: { kind: 'dsh-account', hint: 'Set QUOTA_PILL_DSH_PORT or DSH_RPC_BASE.' },
152
- };
153
- }
154
-
155
44
  // The client metadata identifies the calling UI. `version` is a string here,
156
45
  // not a number — the strict wire codec rejects a numeric version.
157
46
  const res = await dshRpc('account/getBalance', {
158
47
  client: { version: '1', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 },
159
- }, base);
48
+ });
160
49
 
161
50
  if (!res.ok) {
162
51
  return {
package/src/catalog.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Which providers this user actually has.
3
+ *
4
+ * A hardcoded adapter list is wrong in both directions: it lists providers that
5
+ * were removed or never configured, and it cannot know about new ones. DSH's own
6
+ * `session/modelCatalog` is the authoritative answer — it is literally the set of
7
+ * providers the app can route to right now.
8
+ *
9
+ * If the catalog is unavailable (DSH not running), callers fall back to showing
10
+ * whatever the adapters can read, so the pill degrades rather than going blank.
11
+ */
12
+ import { dshRpc } from './dsh.js';
13
+
14
+ const TTL_MS = 120 * 1000;
15
+ let cache = { at: 0, ids: null };
16
+
17
+ /** Provider ids DSH currently exposes, or null when unknown. */
18
+ export async function catalogProviderIds() {
19
+ if (cache.ids && Date.now() - cache.at < TTL_MS) return cache.ids;
20
+
21
+ const res = await dshRpc('session/modelCatalog', {});
22
+ if (!res.ok) return cache.ids; // keep the last good answer, if any
23
+
24
+ const groups = (res.value && res.value.groups) || [];
25
+ const ids = groups.map((g) => g && g.id).filter(Boolean);
26
+ if (!ids.length) return cache.ids;
27
+
28
+ cache = { at: Date.now(), ids };
29
+ return ids;
30
+ }
31
+
32
+ /** Drop the cached catalog (tests, or after installing a provider). */
33
+ export function forgetCatalog() {
34
+ cache = { at: 0, ids: null };
35
+ }
package/src/dsh.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Talking to the running DSH instance.
3
+ *
4
+ * Two things need it:
5
+ * · the DeepSeek balance, which only DSH can read (it holds the Platform grant)
6
+ * · the provider catalog, which says which providers this user actually has
7
+ *
8
+ * Both go through DSH's own RPC, authenticated with a signed session cookie:
9
+ * name = "dsh-auth-" + base64url(sha256(authority))
10
+ * value = "v1." + base64url({version, authority, issuedAt, expiresAt}) + "." + sig
11
+ * `issuedAt` / `expiresAt` are milliseconds and `version` must be the number 1 —
12
+ * the wire codec rejects anything else, and a bad payload fails as 401 even
13
+ * though the signature itself verifies.
14
+ */
15
+ import crypto from 'node:crypto';
16
+ import { execFileSync } from 'node:child_process';
17
+ import { URL } from 'node:url';
18
+ import { browserSessionSecret } from './credentials.js';
19
+
20
+ const b64u = (buf) => Buffer.from(buf).toString('base64url');
21
+
22
+ /**
23
+ * Every loopback port a DSH-named process is listening on.
24
+ *
25
+ * A single guess is not enough: DSH's helper processes also open debugging
26
+ * ports, and picking one of those makes every RPC fail with 404.
27
+ */
28
+ function candidatePorts() {
29
+ const out = [];
30
+ const explicit = process.env.DSH_RPC_BASE || process.env.QUOTA_PILL_DSH_BASE;
31
+ if (explicit) out.push(explicit.replace(/\/$/, ''));
32
+ if (process.env.QUOTA_PILL_DSH_PORT) out.push(`http://127.0.0.1:${process.env.QUOTA_PILL_DSH_PORT}`);
33
+ try {
34
+ const text = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
35
+ encoding: 'utf8',
36
+ timeout: 4000,
37
+ });
38
+ for (const line of text.split('\n')) {
39
+ if (!/DeepSeek|dsh/i.test(line)) continue;
40
+ const m = /127\.0\.0\.1:(\d+)/.exec(line);
41
+ if (!m) continue;
42
+ const base = `http://127.0.0.1:${m[1]}`;
43
+ if (!out.includes(base)) out.push(base);
44
+ }
45
+ } catch {
46
+ /* lsof unavailable — env vars are the only path */
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function signCookie(base, secretB64) {
52
+ const authority = new URL(base).host;
53
+ let raw;
54
+ try {
55
+ raw = Buffer.from(secretB64, 'base64url');
56
+ } catch {
57
+ raw = Buffer.from(secretB64, 'utf8');
58
+ }
59
+ const name = 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest());
60
+ const now = Date.now();
61
+ const payload = { version: 1, authority, issuedAt: now, expiresAt: now + 3600 * 1000 };
62
+ const body = b64u(JSON.stringify(payload));
63
+ const sig = b64u(crypto.createHmac('sha256', raw).update(body).digest());
64
+ return `${name}=v1.${body}.${sig}`;
65
+ }
66
+
67
+ /** POST one RPC to a specific base. */
68
+ async function rpcAt(base, method, args, cookie, timeoutMs = 12000) {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
71
+ try {
72
+ const res = await fetch(base + '/api/' + method.replace(/\./g, '/'), {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Cookie: cookie },
75
+ body: JSON.stringify({
76
+ type: 'client-request',
77
+ rpcId: 'quota-pill-' + method,
78
+ method,
79
+ payload: { args },
80
+ }),
81
+ signal: controller.signal,
82
+ });
83
+ const text = await res.text();
84
+ if (!res.ok) return { ok: false, status: res.status, error: `http ${res.status}` };
85
+ let env;
86
+ try {
87
+ env = JSON.parse(text);
88
+ } catch {
89
+ return { ok: false, error: 'unparsable json' };
90
+ }
91
+ const result = (env && env.result) || {};
92
+ if (result.ok === false) {
93
+ const e = result.error || {};
94
+ return { ok: false, error: `${e.code || 'rpc-failed'}: ${e.message || ''}`.trim() };
95
+ }
96
+ return { ok: true, value: result.value };
97
+ } catch (e) {
98
+ return { ok: false, error: String((e && e.message) || e) };
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Call one RPC against whichever candidate port actually answers.
106
+ *
107
+ * Ports are probed with a cheap, always-present RPC rather than trusted by
108
+ * order, and the winner is remembered so we do not re-probe every call.
109
+ */
110
+ let knownBase = null;
111
+
112
+ export async function dshRpc(method, args) {
113
+ const secret = browserSessionSecret();
114
+ if (!secret) return { ok: false, error: 'no browser-session secret in credentials' };
115
+
116
+ const bases = [];
117
+ if (knownBase) bases.push(knownBase);
118
+ for (const b of candidatePorts()) if (!bases.includes(b)) bases.push(b);
119
+
120
+ let lastError = 'no DSH port responded';
121
+ for (const base of bases) {
122
+ const cookie = signCookie(base, secret);
123
+ const res = await rpcAt(base, method, args, cookie);
124
+ // A 404 means this port is a different HTTP surface (a helper's debug port):
125
+ // remember only ports that actually serve the RPC routes.
126
+ if (res.ok || (res.status && res.status !== 404)) {
127
+ knownBase = base;
128
+ return res;
129
+ }
130
+ lastError = res.error || lastError;
131
+ }
132
+ return { ok: false, error: lastError };
133
+ }
134
+
135
+ /** Reset the remembered port (tests, or after DSH restarts on a new port). */
136
+ export function forgetDshBase() {
137
+ knownBase = null;
138
+ }
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ import { kimi, opencode, commandcode } from './adapters-apikey.js';
24
24
  import { xai, codex, agy } from './adapters-cliproxy.js';
25
25
  import { deepseek } from './adapters-deepseek.js';
26
26
  import { credentialSource, hasCliProxy, CLI_PROXY_DIR, CREDENTIALS_FILE } from './credentials.js';
27
+ import { catalogProviderIds } from './catalog.js';
27
28
 
28
29
  const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
29
30
  const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
@@ -101,11 +102,50 @@ async function readOne(provider, model, { fresh = false } = {}) {
101
102
  return { ...payload, cached: false, at: now };
102
103
  }
103
104
 
105
+ /**
106
+ * Which DSH catalog ids count as "this adapter".
107
+ *
108
+ * DSH names providers after the upstream (`antigravity`, `deepseek-account`,
109
+ * `opencode-go`); the adapters use the short keys the pill has always used.
110
+ */
111
+ const CATALOG_ALIASES = {
112
+ agy: ['antigravity', 'agy'],
113
+ deepseek: ['deepseek-account', 'deepseek'],
114
+ opencode: ['opencode-go', 'opencode'],
115
+ kimi: ['kimi-coding', 'kimi'],
116
+ xai: ['xai'],
117
+ codex: ['codex'],
118
+ commandcode: ['commandcode'],
119
+ };
120
+
121
+ function inCatalog(provider, ids) {
122
+ if (!ids) return true; // catalog unknown: do not hide anything
123
+ const names = CATALOG_ALIASES[provider] || [provider];
124
+ return names.some((n) => ids.includes(n));
125
+ }
126
+
127
+ /**
128
+ * Every provider worth showing: present in DSH's catalog AND actually readable.
129
+ *
130
+ * Two filters, for two different complaints:
131
+ * · catalog — a provider removed from DSH must disappear from the pill, even
132
+ * though its adapter still exists in this package
133
+ * · kind 'none' — a provider with no credential has nothing to show; listing
134
+ * it as "no data source" is noise, and new users mistake it for breakage
135
+ *
136
+ * `probe-failed` deliberately still shows: the provider *is* configured, so a
137
+ * failure is information the user needs.
138
+ */
104
139
  async function readAll({ fresh = false } = {}) {
140
+ const ids = await catalogProviderIds();
141
+ const wanted = Object.keys(ADAPTERS).filter((p) => inCatalog(p, ids));
142
+
105
143
  const entries = await Promise.all(
106
- Object.keys(ADAPTERS).map(async (p) => [p, await readOne(p, null, { fresh })]),
144
+ wanted.map(async (p) => [p, await readOne(p, null, { fresh })]),
145
+ );
146
+ return Object.fromEntries(
147
+ entries.filter(([, v]) => v && v.kind !== 'none'),
107
148
  );
108
- return Object.fromEntries(entries);
109
149
  }
110
150
 
111
151
  /** What is configured and what is missing — powers the agent-driven setup. */