@hystudio/dsh-quota-pill 0.2.3 → 0.2.5
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 +78 -5
- package/package.json +1 -1
- package/src/adapters-opendesign.js +141 -0
- package/src/server.js +5 -1
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
|
@@ -595,6 +595,50 @@ window.__ModuleLoader__.load({
|
|
|
595
595
|
const panelRef = React.useRef(null);
|
|
596
596
|
const prevRunning = React.useRef(running);
|
|
597
597
|
|
|
598
|
+
// 面板位置:固定在视口内,而不是跟着触发器「贴」到哪算哪。
|
|
599
|
+
//
|
|
600
|
+
// 旧写法直接取触发器的 left,面板 340px 宽,窄窗口时右边就被切掉了;
|
|
601
|
+
// bottom 固定向上展开,窗口矮时上面同样被切。这里改成:
|
|
602
|
+
// · 水平:优先对齐触发器左边,再夹到视口内
|
|
603
|
+
// · 垂直:上方放得下就向上,放不下就翻到触发器下面
|
|
604
|
+
// · 两侧都塞不满时给 maxHeight,让面板自己滚动而不是溢出
|
|
605
|
+
const [panelStyle, setPanelStyle] = React.useState(null);
|
|
606
|
+
const GAP = 8; // 与触发器的间距
|
|
607
|
+
const EDGE = 12; // 距视口边缘的最小留白
|
|
608
|
+
const PANEL_MAX_W = 340;
|
|
609
|
+
|
|
610
|
+
/** 把面板夹进视口:水平贴触发器左边但不出界,垂直放不下就翻到下方。 */
|
|
611
|
+
const placePanel = React.useCallback(() => {
|
|
612
|
+
const el = anchorRef.current;
|
|
613
|
+
if (!el) return;
|
|
614
|
+
const r = el.getBoundingClientRect();
|
|
615
|
+
setPanelStyle(computePanelPlacement(
|
|
616
|
+
{ left: r.left, top: r.top, bottom: r.bottom },
|
|
617
|
+
{
|
|
618
|
+
width: window.innerWidth || document.documentElement.clientWidth,
|
|
619
|
+
height: window.innerHeight || document.documentElement.clientHeight,
|
|
620
|
+
},
|
|
621
|
+
panelRef.current ? panelRef.current.offsetHeight : 0,
|
|
622
|
+
GAP, EDGE, PANEL_MAX_W,
|
|
623
|
+
));
|
|
624
|
+
}, []);
|
|
625
|
+
|
|
626
|
+
React.useEffect(() => {
|
|
627
|
+
if (!open) { setPanelStyle(null); return undefined; }
|
|
628
|
+
placePanel();
|
|
629
|
+
// 挂载后量到真实高度再算一次,翻转判定才准
|
|
630
|
+
const raf = requestAnimationFrame(placePanel);
|
|
631
|
+
const onViewportChange = () => placePanel();
|
|
632
|
+
window.addEventListener('resize', onViewportChange);
|
|
633
|
+
// capture: 工具条自身可能滚动,滚动会改变触发器的位置
|
|
634
|
+
window.addEventListener('scroll', onViewportChange, true);
|
|
635
|
+
return () => {
|
|
636
|
+
cancelAnimationFrame(raf);
|
|
637
|
+
window.removeEventListener('resize', onViewportChange);
|
|
638
|
+
window.removeEventListener('scroll', onViewportChange, true);
|
|
639
|
+
};
|
|
640
|
+
}, [open, placePanel]);
|
|
641
|
+
|
|
598
642
|
// 面板页签:默认「当前」(本会话在用的 provider),可切到「全部」。
|
|
599
643
|
const [tab, setTab] = React.useState('current');
|
|
600
644
|
// 切换动画从哪一侧滑入:'right' = 新内容自右来(往前进),'left' = 自左来。
|
|
@@ -926,11 +970,10 @@ window.__ModuleLoader__.load({
|
|
|
926
970
|
role: 'dialog',
|
|
927
971
|
'aria-label': 'quota',
|
|
928
972
|
className: 'quota-pill-panel',
|
|
929
|
-
style:
|
|
930
|
-
position: 'fixed',
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
},
|
|
973
|
+
style: panelStyle
|
|
974
|
+
? { position: 'fixed', ...panelStyle }
|
|
975
|
+
// 第一帧还没算好位置:先藏着,否则会先画在错误的地方再跳一下
|
|
976
|
+
: { position: 'fixed', left: 0, top: 0, visibility: 'hidden' },
|
|
934
977
|
},
|
|
935
978
|
h('div', {
|
|
936
979
|
className: 'quota-pill-tabs',
|
|
@@ -1176,6 +1219,36 @@ window.__ModuleLoader__.load({
|
|
|
1176
1219
|
return Math.abs(credits.total - credits.remain) > 1e-9 || Math.abs(pct - 100) > 0.01;
|
|
1177
1220
|
}
|
|
1178
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* 计算面板该放在哪,纯函数(不碰 DOM,便于测试)。
|
|
1224
|
+
*
|
|
1225
|
+
* 面板固定在视口里,不跟着触发器「贴」到哪算哪:
|
|
1226
|
+
* · 水平:优先对齐触发器左边,再夹进视口,宁可左移也不切边
|
|
1227
|
+
* · 垂直:上方放得下就向上展开,放不下就翻到触发器下方
|
|
1228
|
+
* · 都不够高时返回 maxHeight,让面板内部滚动,绝不溢出视口
|
|
1229
|
+
*
|
|
1230
|
+
* @param anchor 触发器的 getBoundingClientRect 形状 {left, top, bottom}
|
|
1231
|
+
* @param viewport {width, height}
|
|
1232
|
+
* @param panelHeight 面板实测高度,0 表示还没量到
|
|
1233
|
+
*/
|
|
1234
|
+
function computePanelPlacement(anchor, viewport, panelHeight, gap, edge, maxWidth) {
|
|
1235
|
+
const vw = viewport.width;
|
|
1236
|
+
const vh = viewport.height;
|
|
1237
|
+
const width = Math.min(maxWidth, vw - edge * 2);
|
|
1238
|
+
const left = Math.max(edge, Math.min(anchor.left, vw - width - edge));
|
|
1239
|
+
|
|
1240
|
+
const spaceAbove = anchor.top - gap - edge;
|
|
1241
|
+
const spaceBelow = vh - anchor.bottom - gap - edge;
|
|
1242
|
+
const up = panelHeight === 0
|
|
1243
|
+
? spaceAbove >= spaceBelow
|
|
1244
|
+
: (spaceAbove >= panelHeight || spaceAbove >= spaceBelow);
|
|
1245
|
+
|
|
1246
|
+
const base = { left, width, overflowY: 'auto' };
|
|
1247
|
+
return up
|
|
1248
|
+
? { ...base, bottom: Math.max(edge, vh - anchor.top + gap), maxHeight: Math.max(140, spaceAbove), placement: 'up' }
|
|
1249
|
+
: { ...base, top: Math.max(edge, anchor.bottom + gap), maxHeight: Math.max(140, spaceBelow), placement: 'down' };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1179
1252
|
function WindowRow(props) {
|
|
1180
1253
|
const { label, pct, reset, credits, lang, stale } = props;
|
|
1181
1254
|
const isNum = typeof pct === 'number' && Number.isFinite(pct);
|
package/package.json
CHANGED
|
@@ -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/server.js
CHANGED
|
@@ -25,6 +25,7 @@ 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
27
|
import { catalogProviderIds } from './catalog.js';
|
|
28
|
+
import { opendesign } from './adapters-opendesign.js';
|
|
28
29
|
|
|
29
30
|
const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
|
|
30
31
|
const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
|
|
@@ -32,7 +33,7 @@ const CACHE_TTL_MS = Number(process.env.QUOTA_PILL_CACHE_TTL || 60) * 1000;
|
|
|
32
33
|
|
|
33
34
|
// Order matters: the pill lists providers in this order, and the official
|
|
34
35
|
// DeepSeek account is the one most people look for first.
|
|
35
|
-
const ADAPTERS = { deepseek, kimi, opencode, commandcode, xai, codex, agy };
|
|
36
|
+
const ADAPTERS = { deepseek, kimi, opencode, commandcode, xai, codex, agy, opendesign };
|
|
36
37
|
|
|
37
38
|
/** Display names. Deliberately small: the table stays in sync with ADAPTERS. */
|
|
38
39
|
const DISPLAY_NAMES = {
|
|
@@ -43,6 +44,7 @@ const DISPLAY_NAMES = {
|
|
|
43
44
|
xai: 'xAI (Grok)',
|
|
44
45
|
codex: 'Codex (ChatGPT Plus)',
|
|
45
46
|
agy: 'Antigravity',
|
|
47
|
+
opendesign: 'Open Design',
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
/** Which DSH model prefixes map to which adapter. */
|
|
@@ -58,6 +60,7 @@ const PREFIX_ALIASES = {
|
|
|
58
60
|
antigravity: 'agy',
|
|
59
61
|
agy: 'agy',
|
|
60
62
|
deepseek: 'deepseek',
|
|
63
|
+
opendesign: 'opendesign',
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
/** provider|model → { at, payload }. Keyed by model because readings differ per model. */
|
|
@@ -116,6 +119,7 @@ const CATALOG_ALIASES = {
|
|
|
116
119
|
xai: ['xai'],
|
|
117
120
|
codex: ['codex'],
|
|
118
121
|
commandcode: ['commandcode'],
|
|
122
|
+
opendesign: ['opendesign'],
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
function inCatalog(provider, ids) {
|