@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 +1 -0
- package/README.md +13 -2
- package/client.js +3 -3
- package/index.js +14 -3
- package/package.json +1 -1
- package/src/adapters-deepseek.js +3 -114
- package/src/adapters-opendesign.js +141 -0
- package/src/catalog.js +35 -0
- package/src/dsh.js +138 -0
- package/src/server.js +47 -3
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
|
|
34
|
-
|
|
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: '@
|
|
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 = '@
|
|
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 = '@
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
package/src/adapters-deepseek.js
CHANGED
|
@@ -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
|
|
23
|
-
import {
|
|
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
|
-
}
|
|
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
|
-
|
|
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. */
|