@hystudio/dsh-quota-pill 0.2.2 → 0.2.4

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/AGENTS.md CHANGED
@@ -33,6 +33,7 @@ There are **two credential families**, and this matters for every step:
33
33
  | DSH Platform sign-in | `deepseek` | **nothing** — works if they are signed in to DeepSeek Platform in DSH |
34
34
  | API key | `kimi`, `opencode`, `commandcode` | a key pasted into `~/.dsh/.credentials.yaml` |
35
35
  | OAuth via CLIProxyAPI | `xai`, `codex`, `agy` | **CLIProxyAPI installed and logged in once** |
36
+ | Console session cookie | `opendesign` | the `__Secure-better-auth.session_token` cookie from the Open Design cloud dashboard |
36
37
 
37
38
  **Check DeepSeek first.** It is the official account and needs no setup beyond a
38
39
  DSH Platform sign-in, so it is usually the one the user actually wants. If it
package/README.md CHANGED
@@ -22,6 +22,7 @@ into the live number.
22
22
  | **xAI (Grok)** | OAuth token from [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) |
23
23
  | **Codex (ChatGPT Plus)** | same — and CLIProxyAPI must be **running** |
24
24
  | **Antigravity** | same |
25
+ | **Open Design** (optional) | `refs.OPENDESIGN_SESSION_COOKIE` — the cloud console's session cookie |
25
26
 
26
27
  **DeepSeek works with no setup at all** if you are signed in to DeepSeek Platform
27
28
  in DSH — that is the official account, and it is the one most users care about.
@@ -30,8 +31,10 @@ The three API-key providers need nothing but the key. The three OAuth providers
30
31
  need CLIProxyAPI installed and logged in, because that is what holds the tokens;
31
32
  this plugin only reads them and never runs an OAuth flow itself.
32
33
 
33
- Every provider is optional. Unconfigured ones say exactly what is missing instead
34
- of showing a fake zero.
34
+ Every provider is optional, and **only the ones you actually have are listed**:
35
+ the pill reads DSH's `session/modelCatalog` to learn which providers the app can
36
+ route to, so removing a provider from DSH removes it from the pill too. A provider
37
+ that is present but has no credential is skipped rather than shown as an empty row.
35
38
 
36
39
  ## Install
37
40
 
@@ -190,6 +193,14 @@ would flicker between models you do not even have configured.
190
193
  `monthlyCap = weekly.cap × 2`, an identity verified against two different plans
191
194
  (31.6 + 38.4 = 70 with `weekly.cap` 35; 0.225 + 9.775 = 10 with `weekly.cap` 6).
192
195
 
196
+ **Open Design** keeps its allowance in the cloud console, not in the API key you
197
+ use for inference. The `amr-link.open-design.ai/v1` gateway has no balance
198
+ endpoint at all; the console's own `amr-api` does, and it authenticates with a
199
+ `__Secure-better-auth.session_token` cookie. That cookie is HttpOnly, so grab it
200
+ from devtools → Application → Cookies. The workspace id is discovered
201
+ automatically. The request goes through system curl because Cloudflare rejects
202
+ Node's TLS fingerprint on that host with `error code: 1010`.
203
+
193
204
  **DeepSeek** is a prepaid CNY wallet, so there is no percentage to show — the pill
194
205
  shows the balance and, separately, any bonus credit. Two traps worth recording:
195
206
  the stored credential is a *Platform login grant*, not an `api.deepseek.com` API
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.4",
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 {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Open Design (open-design.ai) — Cloud coding-plan allowance.
3
+ *
4
+ * Optional provider. Unlike the others it does **not** read an API key: the
5
+ * allowance lives in the cloud console's own API and is authenticated with the
6
+ * console's session cookie. Set it once:
7
+ *
8
+ * ~/.dsh/.credentials.yaml
9
+ * refs:
10
+ * OPENDESIGN_SESSION_COOKIE: <__Secure-better-auth.session_token value>
11
+ *
12
+ * To get that value: open https://open-design.ai/cloud/dashboard while signed in,
13
+ * then in the browser's devtools read the `__Secure-better-auth.session_token`
14
+ * cookie (it is HttpOnly, so `document.cookie` will not show it).
15
+ *
16
+ * Two traps, both verified:
17
+ * · The inference gateway at amr-link.open-design.ai/v1 has NO balance
18
+ * endpoint — it is a chat proxy only. Probing it for quota always 404s.
19
+ * · amr-api sits behind Cloudflare, which rejects Node's TLS fingerprint with
20
+ * `error code: 1010`. The request therefore goes through system curl.
21
+ * That is the same request over a different TLS stack, not an auth bypass.
22
+ */
23
+ import { readRef } from './credentials.js';
24
+ import { getJsonViaCurl } from './http.js';
25
+
26
+ const API = process.env.OPENDESIGN_API_BASE || 'https://amr-api.open-design.ai/api/v1';
27
+
28
+ const round2 = (n) => Math.round(n * 100) / 100;
29
+
30
+ function headers(cookie) {
31
+ return {
32
+ Cookie: '__Secure-better-auth.session_token=' + cookie,
33
+ Origin: 'https://open-design.ai',
34
+ Referer: 'https://open-design.ai/',
35
+ };
36
+ }
37
+
38
+ /** Window label from its length: 18000s is the 5h cap, 604800s the weekly one. */
39
+ function windowKey(seconds) {
40
+ const s = Number(seconds) || 0;
41
+ if (s > 0 && s <= 6 * 3600) return '5h';
42
+ if (s > 0 && s <= 8 * 86400) return 'weekly';
43
+ if (s > 0) return 'monthly';
44
+ return 'credits-all';
45
+ }
46
+
47
+ /**
48
+ * The workspace id is discoverable, so the user only supplies a cookie.
49
+ * `/workspaces/current` already resolves to the default workspace.
50
+ */
51
+ async function currentWorkspace(cookie) {
52
+ const res = await getJsonViaCurl(`${API}/workspaces/current`, { headers: headers(cookie) });
53
+ if (!res.ok) return { error: res.error };
54
+ const id = res.data && res.data.workspaceId;
55
+ return id ? { id } : { error: 'no workspaceId in response' };
56
+ }
57
+
58
+ export async function opendesign() {
59
+ const cookie = readRef('OPENDESIGN_SESSION_COOKIE');
60
+ if (!cookie) {
61
+ return {
62
+ kind: 'none',
63
+ provider: 'opendesign',
64
+ error: 'no OPENDESIGN_SESSION_COOKIE in ~/.dsh/.credentials.yaml',
65
+ setup: {
66
+ kind: 'session-cookie',
67
+ ref: 'OPENDESIGN_SESSION_COOKIE',
68
+ hint: 'Copy the __Secure-better-auth.session_token cookie from the Open Design cloud dashboard.',
69
+ },
70
+ };
71
+ }
72
+
73
+ const ws = await currentWorkspace(cookie);
74
+ if (ws.error) {
75
+ return {
76
+ kind: 'probe-failed',
77
+ provider: 'opendesign',
78
+ error: `workspace lookup failed: ${ws.error}`,
79
+ hint: 'The console session cookie has probably expired — copy a fresh one.',
80
+ };
81
+ }
82
+
83
+ const res = await getJsonViaCurl(
84
+ `${API}/workspaces/${ws.id}/billing/coding-plan-usage`,
85
+ { headers: headers(cookie), timeoutMs: 25000 },
86
+ );
87
+ if (!res.ok) {
88
+ return {
89
+ kind: 'probe-failed',
90
+ provider: 'opendesign',
91
+ error: `coding-plan-usage failed: ${res.error}`,
92
+ hint: res.error && res.error.includes('40')
93
+ ? 'The console session cookie has probably expired — copy a fresh one.'
94
+ : undefined,
95
+ };
96
+ }
97
+
98
+ const body = res.data || {};
99
+ const windows = [];
100
+ for (const w of body.windows || []) {
101
+ const limit = Number(w.limitCredits);
102
+ const remain = Number(w.remainingCredits);
103
+ if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(remain)) continue;
104
+ windows.push({
105
+ key: windowKey(w.durationSeconds),
106
+ remainPct: round2((remain / limit) * 100),
107
+ resetAt: w.resetsAt || null,
108
+ credits: { remain: round2(remain), total: round2(limit), unit: 'credits' },
109
+ });
110
+ }
111
+
112
+ if (!windows.length) {
113
+ return {
114
+ kind: 'none',
115
+ provider: 'opendesign',
116
+ error: `coding-plan-usage returned no windows (tier ${body.tier || 'unknown'})`,
117
+ };
118
+ }
119
+
120
+ // The tightest window is what blocks the next request, but the list itself
121
+ // stays in canonical order (5h before weekly) so rows match every other
122
+ // provider instead of reshuffling as the numbers move.
123
+ const tight = windows.reduce((a, b) => (a.remainPct <= b.remainPct ? a : b));
124
+ const ORDER = { '5h': 0, weekly: 1, monthly: 2, 'credits-all': 3 };
125
+ windows.sort((a, b) => (ORDER[a.key] ?? 9) - (ORDER[b.key] ?? 9));
126
+ const products = {};
127
+ for (const w of windows) products[w.key] = round2(100 - w.remainPct);
128
+
129
+ return {
130
+ kind: 'subscription-quota',
131
+ provider: 'opendesign',
132
+ account: body.tier || null,
133
+ remainingPct: tight.remainPct,
134
+ usedPct: round2(100 - tight.remainPct),
135
+ window: tight.key,
136
+ resetAt: tight.resetAt,
137
+ products,
138
+ windows,
139
+ raw: { source: 'coding-plan-usage (console session)', tier: body.tier },
140
+ };
141
+ }
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,8 @@ 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';
28
+ import { opendesign } from './adapters-opendesign.js';
27
29
 
28
30
  const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
29
31
  const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
@@ -31,7 +33,7 @@ const CACHE_TTL_MS = Number(process.env.QUOTA_PILL_CACHE_TTL || 60) * 1000;
31
33
 
32
34
  // Order matters: the pill lists providers in this order, and the official
33
35
  // DeepSeek account is the one most people look for first.
34
- const ADAPTERS = { deepseek, kimi, opencode, commandcode, xai, codex, agy };
36
+ const ADAPTERS = { deepseek, kimi, opencode, commandcode, xai, codex, agy, opendesign };
35
37
 
36
38
  /** Display names. Deliberately small: the table stays in sync with ADAPTERS. */
37
39
  const DISPLAY_NAMES = {
@@ -42,6 +44,7 @@ const DISPLAY_NAMES = {
42
44
  xai: 'xAI (Grok)',
43
45
  codex: 'Codex (ChatGPT Plus)',
44
46
  agy: 'Antigravity',
47
+ opendesign: 'Open Design',
45
48
  };
46
49
 
47
50
  /** Which DSH model prefixes map to which adapter. */
@@ -57,6 +60,7 @@ const PREFIX_ALIASES = {
57
60
  antigravity: 'agy',
58
61
  agy: 'agy',
59
62
  deepseek: 'deepseek',
63
+ opendesign: 'opendesign',
60
64
  };
61
65
 
62
66
  /** provider|model → { at, payload }. Keyed by model because readings differ per model. */
@@ -101,11 +105,51 @@ async function readOne(provider, model, { fresh = false } = {}) {
101
105
  return { ...payload, cached: false, at: now };
102
106
  }
103
107
 
108
+ /**
109
+ * Which DSH catalog ids count as "this adapter".
110
+ *
111
+ * DSH names providers after the upstream (`antigravity`, `deepseek-account`,
112
+ * `opencode-go`); the adapters use the short keys the pill has always used.
113
+ */
114
+ const CATALOG_ALIASES = {
115
+ agy: ['antigravity', 'agy'],
116
+ deepseek: ['deepseek-account', 'deepseek'],
117
+ opencode: ['opencode-go', 'opencode'],
118
+ kimi: ['kimi-coding', 'kimi'],
119
+ xai: ['xai'],
120
+ codex: ['codex'],
121
+ commandcode: ['commandcode'],
122
+ opendesign: ['opendesign'],
123
+ };
124
+
125
+ function inCatalog(provider, ids) {
126
+ if (!ids) return true; // catalog unknown: do not hide anything
127
+ const names = CATALOG_ALIASES[provider] || [provider];
128
+ return names.some((n) => ids.includes(n));
129
+ }
130
+
131
+ /**
132
+ * Every provider worth showing: present in DSH's catalog AND actually readable.
133
+ *
134
+ * Two filters, for two different complaints:
135
+ * · catalog — a provider removed from DSH must disappear from the pill, even
136
+ * though its adapter still exists in this package
137
+ * · kind 'none' — a provider with no credential has nothing to show; listing
138
+ * it as "no data source" is noise, and new users mistake it for breakage
139
+ *
140
+ * `probe-failed` deliberately still shows: the provider *is* configured, so a
141
+ * failure is information the user needs.
142
+ */
104
143
  async function readAll({ fresh = false } = {}) {
144
+ const ids = await catalogProviderIds();
145
+ const wanted = Object.keys(ADAPTERS).filter((p) => inCatalog(p, ids));
146
+
105
147
  const entries = await Promise.all(
106
- Object.keys(ADAPTERS).map(async (p) => [p, await readOne(p, null, { fresh })]),
148
+ wanted.map(async (p) => [p, await readOne(p, null, { fresh })]),
149
+ );
150
+ return Object.fromEntries(
151
+ entries.filter(([, v]) => v && v.kind !== 'none'),
107
152
  );
108
- return Object.fromEntries(entries);
109
153
  }
110
154
 
111
155
  /** What is configured and what is missing — powers the agent-driven setup. */