@hystudio/dsh-quota-pill 0.2.6 → 0.2.11
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 +188 -20
- package/README.md +85 -17
- package/bin/opendesign-login.js +80 -0
- package/client.js +148 -21
- package/docs/screenshot.png +0 -0
- package/index.js +12 -4
- package/package.json +5 -3
- package/src/adapters-apikey.js +45 -8
- package/src/adapters-cliproxy.js +13 -2
- package/src/adapters-opendesign.js +199 -57
- package/src/autostart.js +64 -0
- package/src/credentials.js +60 -9
- package/src/http.js +45 -0
- package/src/opendesign-auth.js +95 -0
- package/src/providers.js +112 -0
- package/src/server.js +71 -55
- package/src/user-adapters.js +172 -0
|
@@ -1,42 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Open Design (open-design.ai) — Cloud coding-plan allowance.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Two ways in, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Control key** (preferred): `vela_ctrl_…` from an Open Design account
|
|
7
|
+
* sign-in. It is accepted by the Bearer-only control-plane endpoints
|
|
8
|
+
* `/billing/preflight` and `/wallet/balance`, alongside
|
|
9
|
+
* `x-vela-workspace-id`. This is the same credential the Open Design CLI
|
|
10
|
+
* (`vela login`) and the magpie/OpenCode provider use.
|
|
11
|
+
* 2. **Console session cookie** (fallback): `__Secure-better-auth.session_token`
|
|
12
|
+
* from the Cloud console; it can read the browser-session-only
|
|
13
|
+
* `/billing/coding-plan-usage` route.
|
|
14
|
+
*
|
|
15
|
+
* Configure one of:
|
|
7
16
|
*
|
|
8
17
|
* ~/.dsh/.credentials.yaml
|
|
9
18
|
* refs:
|
|
19
|
+
* OPENDESIGN_CONTROL_KEY: vela_ctrl_…
|
|
20
|
+
* OPENDESIGN_WORKSPACE_ID: br542…
|
|
21
|
+
* # or
|
|
10
22
|
* OPENDESIGN_SESSION_COOKIE: <__Secure-better-auth.session_token value>
|
|
11
23
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* cookie (it is HttpOnly, so `document.cookie` will not show it).
|
|
24
|
+
* The workspace id is optional when a control key is set: the adapter resolves
|
|
25
|
+
* it through `/workspaces/current`.
|
|
15
26
|
*
|
|
16
27
|
* Two traps, both verified:
|
|
17
28
|
* · The inference gateway at amr-link.open-design.ai/v1 has NO balance
|
|
18
29
|
* endpoint — it is a chat proxy only. Probing it for quota always 404s.
|
|
19
30
|
* · amr-api sits behind Cloudflare, which rejects Node's TLS fingerprint with
|
|
20
|
-
* `error code: 1010`.
|
|
21
|
-
*
|
|
31
|
+
* `error code: 1010`. Requests therefore go through system curl. That is
|
|
32
|
+
* the same request over a different TLS stack, not an auth bypass.
|
|
33
|
+
*
|
|
34
|
+
* The control-plane endpoint accepts only the control key; an inference
|
|
35
|
+
* `sk-amr-…` API key is a different credential and returns 401 here.
|
|
22
36
|
*/
|
|
23
|
-
import { readRef } from './credentials.js';
|
|
37
|
+
import { readRef, readFirstRef } from './credentials.js';
|
|
24
38
|
import { getJsonViaCurl } from './http.js';
|
|
25
39
|
|
|
26
40
|
const API = process.env.OPENDESIGN_API_BASE || 'https://amr-api.open-design.ai/api/v1';
|
|
41
|
+
const ORIGIN = 'https://open-design.ai';
|
|
42
|
+
const CONTROL_REFS = ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'];
|
|
43
|
+
const WORKSPACE_REFS = ['OPENDESIGN_WORKSPACE_ID', 'VELA_WORKSPACE_ID', 'OPENDESIGN_WORKSPACE'];
|
|
44
|
+
const COOKIE_REF = 'OPENDESIGN_SESSION_COOKIE';
|
|
27
45
|
|
|
28
46
|
const round2 = (n) => Math.round(n * 100) / 100;
|
|
29
47
|
|
|
30
|
-
|
|
48
|
+
/** Headers both control-plane reads need. */
|
|
49
|
+
function controlHeaders(workspaceId) {
|
|
50
|
+
return {
|
|
51
|
+
'x-vela-workspace-id': workspaceId,
|
|
52
|
+
Origin: ORIGIN,
|
|
53
|
+
Referer: ORIGIN + '/',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cookieHeaders(cookie) {
|
|
31
58
|
return {
|
|
32
59
|
Cookie: '__Secure-better-auth.session_token=' + cookie,
|
|
33
|
-
Origin:
|
|
34
|
-
Referer: '
|
|
60
|
+
Origin: ORIGIN,
|
|
61
|
+
Referer: ORIGIN + '/',
|
|
35
62
|
};
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
/** Window label from its length: 18000s is the 5h cap, 604800s the weekly one. */
|
|
39
|
-
function windowKey(seconds) {
|
|
66
|
+
export function windowKey(seconds) {
|
|
40
67
|
const s = Number(seconds) || 0;
|
|
41
68
|
if (s > 0 && s <= 6 * 3600) return '5h';
|
|
42
69
|
if (s > 0 && s <= 8 * 86400) return 'weekly';
|
|
@@ -45,50 +72,151 @@ function windowKey(seconds) {
|
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
/**
|
|
48
|
-
* The workspace
|
|
49
|
-
*
|
|
75
|
+
* The workspace whose allowance is shown. A control key can resolve its own
|
|
76
|
+
* active workspace; an explicit ref wins when the account has more than one.
|
|
50
77
|
*/
|
|
51
|
-
async function
|
|
52
|
-
const
|
|
78
|
+
async function workspaceForControl(controlKey) {
|
|
79
|
+
const configured = readFirstRef(WORKSPACE_REFS);
|
|
80
|
+
if (configured) return { id: configured };
|
|
81
|
+
const res = await getJsonViaCurl(API + '/workspaces/current', {
|
|
82
|
+
token: controlKey,
|
|
83
|
+
headers: { Origin: ORIGIN, Referer: ORIGIN + '/' },
|
|
84
|
+
});
|
|
53
85
|
if (!res.ok) return { error: res.error };
|
|
54
|
-
const
|
|
55
|
-
|
|
86
|
+
const body = res.data || {};
|
|
87
|
+
const id = body.activeWorkspaceId || body.workspaceId;
|
|
88
|
+
return id ? { id } : { error: 'no activeWorkspaceId in /workspaces/current' };
|
|
56
89
|
}
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Turn the control-plane preflight's `codingPlan.windows` into the pill's
|
|
93
|
+
* window rows. The preflight reports **used** credits; the pill renders
|
|
94
|
+
* **remaining** percent, so the conversion happens once here.
|
|
95
|
+
*/
|
|
96
|
+
export function preflightWindows(body) {
|
|
97
|
+
const plan = body && body.codingPlan;
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const w of (plan && plan.windows) || []) {
|
|
100
|
+
const limit = Number(w.limitCredits);
|
|
101
|
+
const used = Number(w.usedCredits);
|
|
102
|
+
if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(used)) continue;
|
|
103
|
+
const remain = Math.max(0, limit - used);
|
|
104
|
+
out.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
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Shared result shape for both credential paths (and the tests). */
|
|
115
|
+
export function shapeReading({ windows, balanceUsd, tier = null, source }) {
|
|
116
|
+
if (windows.length) {
|
|
117
|
+
const tight = windows.reduce((a, b) => (a.remainPct <= b.remainPct ? a : b));
|
|
118
|
+
const ORDER = { '5h': 0, weekly: 1, monthly: 2, 'credits-all': 3 };
|
|
119
|
+
windows.sort((a, b) => (ORDER[a.key] ?? 9) - (ORDER[b.key] ?? 9));
|
|
120
|
+
const products = {};
|
|
121
|
+
for (const w of windows) products[w.key] = round2(100 - w.remainPct);
|
|
61
122
|
return {
|
|
62
|
-
kind: '
|
|
123
|
+
kind: 'subscription-quota',
|
|
63
124
|
provider: 'opendesign',
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
125
|
+
account: tier || null,
|
|
126
|
+
remainingPct: tight.remainPct,
|
|
127
|
+
usedPct: round2(100 - tight.remainPct),
|
|
128
|
+
window: tight.key,
|
|
129
|
+
resetAt: tight.resetAt,
|
|
130
|
+
products,
|
|
131
|
+
windows,
|
|
132
|
+
raw: { source, tier },
|
|
70
133
|
};
|
|
71
134
|
}
|
|
72
135
|
|
|
73
|
-
const
|
|
136
|
+
const balance = Number(balanceUsd);
|
|
137
|
+
if (Number.isFinite(balance)) {
|
|
138
|
+
return {
|
|
139
|
+
kind: 'payg',
|
|
140
|
+
provider: 'opendesign',
|
|
141
|
+
account: tier || null,
|
|
142
|
+
note: 'wallet balance $' + balance.toFixed(2),
|
|
143
|
+
remainingPct: null,
|
|
144
|
+
usedPct: null,
|
|
145
|
+
credits: { remain: round2(balance), total: null, unit: 'USD' },
|
|
146
|
+
raw: { source, tier },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
kind: 'none',
|
|
152
|
+
provider: 'opendesign',
|
|
153
|
+
error: source + ' returned no allowance windows and no wallet balance',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Control key: Bearer-only `/billing/preflight` plus the wallet balance. */
|
|
158
|
+
async function readWithControl(controlKey) {
|
|
159
|
+
const ws = await workspaceForControl(controlKey);
|
|
74
160
|
if (ws.error) {
|
|
75
161
|
return {
|
|
76
162
|
kind: 'probe-failed',
|
|
77
163
|
provider: 'opendesign',
|
|
78
|
-
error:
|
|
164
|
+
error: 'workspace lookup failed: ' + ws.error,
|
|
165
|
+
hint: 'A Control Key (vela_ctrl_…) is required; an inference sk-amr-… key cannot read the control plane.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const res = await getJsonViaCurl(
|
|
170
|
+
API + '/workspaces/' + encodeURIComponent(ws.id) + '/billing/preflight',
|
|
171
|
+
{ token: controlKey, headers: controlHeaders(ws.id), timeoutMs: 25000 },
|
|
172
|
+
);
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const denied = res.status === 401 || res.status === 403;
|
|
175
|
+
return {
|
|
176
|
+
kind: 'probe-failed',
|
|
177
|
+
provider: 'opendesign',
|
|
178
|
+
error: 'billing/preflight failed: ' + res.error,
|
|
179
|
+
hint: denied
|
|
180
|
+
? 'The Control Key is invalid, expired, or not allowed to read this workspace.'
|
|
181
|
+
: undefined,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const body = res.data || {};
|
|
186
|
+
const plan = body.codingPlan || null;
|
|
187
|
+
return shapeReading({
|
|
188
|
+
windows: preflightWindows(body),
|
|
189
|
+
balanceUsd: body.balanceUsd ?? (plan && plan.balanceUsd),
|
|
190
|
+
tier: (plan && (plan.tier || plan.membershipTier)) || body.tier || null,
|
|
191
|
+
source: 'billing/preflight (control key)',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Console session cookie: browser-session-only `/billing/coding-plan-usage`. */
|
|
196
|
+
async function readWithCookie(cookie) {
|
|
197
|
+
const wsRes = await getJsonViaCurl(API + '/workspaces/current', { headers: cookieHeaders(cookie) });
|
|
198
|
+
if (!wsRes.ok) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'probe-failed',
|
|
201
|
+
provider: 'opendesign',
|
|
202
|
+
error: 'workspace lookup failed: ' + wsRes.error,
|
|
79
203
|
hint: 'The console session cookie has probably expired — copy a fresh one.',
|
|
80
204
|
};
|
|
81
205
|
}
|
|
206
|
+
const id = (wsRes.data && (wsRes.data.activeWorkspaceId || wsRes.data.workspaceId));
|
|
207
|
+
if (!id) {
|
|
208
|
+
return { kind: 'probe-failed', provider: 'opendesign', error: 'no workspaceId in /workspaces/current' };
|
|
209
|
+
}
|
|
82
210
|
|
|
83
211
|
const res = await getJsonViaCurl(
|
|
84
|
-
|
|
85
|
-
{ headers:
|
|
212
|
+
API + '/workspaces/' + encodeURIComponent(id) + '/billing/coding-plan-usage',
|
|
213
|
+
{ headers: cookieHeaders(cookie), timeoutMs: 25000 },
|
|
86
214
|
);
|
|
87
215
|
if (!res.ok) {
|
|
88
216
|
return {
|
|
89
217
|
kind: 'probe-failed',
|
|
90
218
|
provider: 'opendesign',
|
|
91
|
-
error:
|
|
219
|
+
error: 'coding-plan-usage failed: ' + res.error,
|
|
92
220
|
hint: res.error && res.error.includes('40')
|
|
93
221
|
? 'The console session cookie has probably expired — copy a fresh one.'
|
|
94
222
|
: undefined,
|
|
@@ -108,34 +236,48 @@ export async function opendesign() {
|
|
|
108
236
|
credits: { remain: round2(remain), total: round2(limit), unit: 'credits' },
|
|
109
237
|
});
|
|
110
238
|
}
|
|
239
|
+
return shapeReading({
|
|
240
|
+
windows,
|
|
241
|
+
balanceUsd: body.balanceUsd,
|
|
242
|
+
tier: body.tier || null,
|
|
243
|
+
source: 'coding-plan-usage (console session)',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
111
246
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
247
|
+
export async function opendesign() {
|
|
248
|
+
const controlKey = readFirstRef(CONTROL_REFS);
|
|
249
|
+
const cookie = readRef(COOKIE_REF);
|
|
250
|
+
|
|
251
|
+
let controlResult = null;
|
|
252
|
+
if (controlKey) {
|
|
253
|
+
controlResult = await readWithControl(controlKey);
|
|
254
|
+
if (controlResult.kind === 'subscription-quota' || controlResult.kind === 'payg') {
|
|
255
|
+
return controlResult;
|
|
256
|
+
}
|
|
118
257
|
}
|
|
119
258
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
259
|
+
if (cookie) {
|
|
260
|
+
const cookieResult = await readWithCookie(cookie);
|
|
261
|
+
if (cookieResult.kind === 'subscription-quota' || cookieResult.kind === 'payg') {
|
|
262
|
+
return cookieResult;
|
|
263
|
+
}
|
|
264
|
+
return controlResult || cookieResult;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (controlResult) return controlResult;
|
|
128
268
|
|
|
129
269
|
return {
|
|
130
|
-
kind: '
|
|
270
|
+
kind: 'none',
|
|
131
271
|
provider: 'opendesign',
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
272
|
+
error: 'no OPENDESIGN_CONTROL_KEY (or VELA_CONTROL_KEY) and no OPENDESIGN_SESSION_COOKIE in ~/.dsh/.credentials.yaml',
|
|
273
|
+
setup: {
|
|
274
|
+
kind: 'control-key',
|
|
275
|
+
ref: 'OPENDESIGN_CONTROL_KEY',
|
|
276
|
+
refs: CONTROL_REFS,
|
|
277
|
+
workspaceRefs: WORKSPACE_REFS,
|
|
278
|
+
cookieRef: COOKIE_REF,
|
|
279
|
+
hint: 'Add the Control Key from an Open Design account sign-in (vela_ctrl_…); '
|
|
280
|
+
+ 'a console session cookie also works as a fallback.',
|
|
281
|
+
},
|
|
140
282
|
};
|
|
141
283
|
}
|
package/src/autostart.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start the sidecar as soon as the DSH host plugin loads.
|
|
3
|
+
*
|
|
4
|
+
* The pill is useless without the sidecar, and asking a fresh user to install a
|
|
5
|
+
* launchd/systemd unit before they see any quota is the opposite of out-of-box.
|
|
6
|
+
* The host half therefore spawns the bundled server when nothing is listening
|
|
7
|
+
* on the port.
|
|
8
|
+
*
|
|
9
|
+
* - If a sidecar is already running (launchd, systemd, a previous DSH launch),
|
|
10
|
+
* the port check makes this a no-op instead of a duplicate.
|
|
11
|
+
* - The child is detached so it survives a DSH window reload and can serve the
|
|
12
|
+
* next launch too. QUOTA_PILL_NO_AUTOSTART=1 opts out (service managers).
|
|
13
|
+
* - Electron's executable is not Node; ELECTRON_RUN_AS_NODE=1 turns it into one.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORT = 8319;
|
|
21
|
+
const SERVER = fileURLToPath(new URL('../bin/quota-pill-server.js', import.meta.url));
|
|
22
|
+
|
|
23
|
+
export function sidecarPort() {
|
|
24
|
+
const port = Number(process.env.QUOTA_PILL_PORT || DEFAULT_PORT);
|
|
25
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_PORT;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when something (healthy or not) is already listening. */
|
|
29
|
+
export function isPortOpen(port, timeoutMs = 500) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const socket = net.connect({ host: '127.0.0.1', port });
|
|
32
|
+
let settled = false;
|
|
33
|
+
const done = (value) => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
socket.destroy();
|
|
37
|
+
resolve(value);
|
|
38
|
+
};
|
|
39
|
+
socket.once('connect', () => done(true));
|
|
40
|
+
socket.once('error', () => done(false));
|
|
41
|
+
// A port that accepts TCP but never speaks still means "do not start a
|
|
42
|
+
// second copy"; the health endpoint is the client's job.
|
|
43
|
+
socket.setTimeout(timeoutMs, () => done(true));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function ensureSidecar({ port = sidecarPort(), server = SERVER, env = process.env } = {}) {
|
|
48
|
+
if (env.QUOTA_PILL_NO_AUTOSTART === '1' || env.QUOTA_PILL_NO_AUTOSTART === 'true') return null;
|
|
49
|
+
if (!fs.existsSync(server)) return null;
|
|
50
|
+
if (await isPortOpen(port)) return null;
|
|
51
|
+
|
|
52
|
+
const childEnv = { ...env, QUOTA_PILL_PORT: String(port) };
|
|
53
|
+
if (process.versions.electron && !childEnv.ELECTRON_RUN_AS_NODE) {
|
|
54
|
+
childEnv.ELECTRON_RUN_AS_NODE = '1';
|
|
55
|
+
}
|
|
56
|
+
const child = spawn(process.execPath, [server], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: 'ignore',
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
env: childEnv,
|
|
61
|
+
});
|
|
62
|
+
child.unref();
|
|
63
|
+
return child;
|
|
64
|
+
}
|
package/src/credentials.js
CHANGED
|
@@ -82,14 +82,33 @@ function unquote(v) {
|
|
|
82
82
|
return v;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Pick the first present, non-empty ref from an ordered list. */
|
|
86
|
+
export function pickFirstRef(refs, names) {
|
|
87
|
+
for (const name of names || []) {
|
|
88
|
+
const v = refs && refs[name];
|
|
89
|
+
if (typeof v === 'string' && v) return v;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
/** Read one DSH credential ref. Returns null when absent. */
|
|
86
95
|
export function readRef(name) {
|
|
96
|
+
return readFirstRef([name]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the first of several acceptable credential refs.
|
|
101
|
+
*
|
|
102
|
+
* DSH names a provider's key after its configured `apiKeyEnv` (for example
|
|
103
|
+
* `COMMANDCODE_GOAT_API_KEY`), while older docs and other setups used the
|
|
104
|
+
* short provider name. Accepting both keeps a key the user already has
|
|
105
|
+
* visible instead of reporting the provider as unconfigured.
|
|
106
|
+
*/
|
|
107
|
+
export function readFirstRef(names) {
|
|
87
108
|
try {
|
|
88
109
|
const text = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
|
89
110
|
const doc = parseCredentialsYaml(text);
|
|
90
|
-
|
|
91
|
-
const v = refs[name];
|
|
92
|
-
return typeof v === 'string' && v ? v : null;
|
|
111
|
+
return pickFirstRef(doc.refs || {}, names);
|
|
93
112
|
} catch {
|
|
94
113
|
return null;
|
|
95
114
|
}
|
|
@@ -149,17 +168,49 @@ export function hasCliProxy() {
|
|
|
149
168
|
}
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
/** Credential sources contributed by user adapters, keyed by provider id. */
|
|
172
|
+
const userCredentialSources = new Map();
|
|
173
|
+
|
|
174
|
+
export function registerCredentialSource(provider, source) {
|
|
175
|
+
if (provider && source) userCredentialSources.set(String(provider), source);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function clearUserCredentialSources() {
|
|
179
|
+
userCredentialSources.clear();
|
|
180
|
+
}
|
|
181
|
+
|
|
152
182
|
/** Where a given provider's credentials come from, for the setup docs / AGENTS.md. */
|
|
153
183
|
export function credentialSource(provider) {
|
|
184
|
+
const fromAdapter = userCredentialSources.get(provider);
|
|
185
|
+
if (fromAdapter) return fromAdapter;
|
|
154
186
|
if (provider === 'xai' || provider === 'codex' || provider === 'agy') {
|
|
155
|
-
return { kind: 'cli-proxy', dir: CLI_PROXY_DIR, ref: null };
|
|
187
|
+
return { kind: 'cli-proxy', dir: CLI_PROXY_DIR, ref: null, refs: [] };
|
|
188
|
+
}
|
|
189
|
+
if (provider === 'deepseek') {
|
|
190
|
+
// The official account is read through DSH's own Platform sign-in; there is
|
|
191
|
+
// no credential the user can add to this file.
|
|
192
|
+
return { kind: 'dsh-account', dir: null, ref: null, refs: [] };
|
|
193
|
+
}
|
|
194
|
+
if (provider === 'opendesign') {
|
|
195
|
+
// The quota control plane authenticates with a Control Key (vela_ctrl_…);
|
|
196
|
+
// a console session cookie is a fallback. The inference sk-amr-… key is a
|
|
197
|
+
// different credential and cannot read it.
|
|
198
|
+
return {
|
|
199
|
+
kind: 'control-key',
|
|
200
|
+
dir: null,
|
|
201
|
+
ref: 'OPENDESIGN_CONTROL_KEY',
|
|
202
|
+
refs: ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'],
|
|
203
|
+
workspaceRefs: ['OPENDESIGN_WORKSPACE_ID', 'VELA_WORKSPACE_ID'],
|
|
204
|
+
cookieRef: 'OPENDESIGN_SESSION_COOKIE',
|
|
205
|
+
};
|
|
156
206
|
}
|
|
157
|
-
const
|
|
158
|
-
kimi: 'KIMI_CODING_API_KEY',
|
|
159
|
-
commandcode: 'COMMANDCODE_API_KEY',
|
|
160
|
-
opencode: 'OPENCODE_GO_API_KEY',
|
|
207
|
+
const refsByProvider = {
|
|
208
|
+
kimi: ['KIMI_CODING_API_KEY'],
|
|
209
|
+
commandcode: ['COMMANDCODE_API_KEY', 'COMMANDCODE_GOAT_API_KEY'],
|
|
210
|
+
opencode: ['OPENCODE_GO_API_KEY'],
|
|
161
211
|
};
|
|
162
|
-
|
|
212
|
+
const refs = refsByProvider[provider] || [];
|
|
213
|
+
return { kind: 'api-key', dir: null, ref: refs[0] || null, refs };
|
|
163
214
|
}
|
|
164
215
|
|
|
165
216
|
/**
|
package/src/http.js
CHANGED
|
@@ -67,6 +67,51 @@ export function getJsonViaCurl(url, { token, headers = {}, timeoutMs = DEFAULT_T
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** POST JSON through the system curl (device authorization, control-plane calls). */
|
|
71
|
+
export function postJsonViaCurl(url, body, { token, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
72
|
+
const args = [
|
|
73
|
+
'-sS',
|
|
74
|
+
'--max-time', String(Math.ceil(timeoutMs / 1000)),
|
|
75
|
+
'-H', 'Accept: application/json',
|
|
76
|
+
'-H', 'Content-Type: application/json',
|
|
77
|
+
'-w', '\n%{http_code}',
|
|
78
|
+
];
|
|
79
|
+
if (!proxyUrl()) args.push('--noproxy', '*');
|
|
80
|
+
if (token) args.push('-H', 'Authorization: Bearer ' + token);
|
|
81
|
+
if (proxyUrl()) args.push('--proxy', proxyUrl());
|
|
82
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
83
|
+
if (v != null && v !== '') args.push('-H', `${k}: ${v}`);
|
|
84
|
+
}
|
|
85
|
+
args.push('--data', JSON.stringify(body ?? {}));
|
|
86
|
+
args.push(url);
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
execFile('curl', args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
90
|
+
if (err && !stdout) {
|
|
91
|
+
resolve({ ok: false, error: String(err.message || err) });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const text = String(stdout || '');
|
|
95
|
+
const cut = text.lastIndexOf('\n');
|
|
96
|
+
if (cut < 0) {
|
|
97
|
+
resolve({ ok: false, error: 'empty response' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const responseBody = text.slice(0, cut);
|
|
101
|
+
const status = Number(text.slice(cut + 1).trim());
|
|
102
|
+
if (status < 200 || status >= 300) {
|
|
103
|
+
resolve({ ok: false, status, error: `http ${status}: ${responseBody.slice(0, 160)}` });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
resolve({ ok: true, status, data: JSON.parse(responseBody) });
|
|
108
|
+
} catch {
|
|
109
|
+
resolve({ ok: false, status, error: 'unparsable json: ' + responseBody.slice(0, 120) });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
70
115
|
/** GET JSON with fetch. Cheaper; fine for upstreams without TLS fingerprinting. */
|
|
71
116
|
export async function getJson(url, { token, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
72
117
|
const controller = new AbortController();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Design account sign-in — the device authorization `vela login` runs.
|
|
3
|
+
*
|
|
4
|
+
* The control plane (https://amr-api.open-design.ai) issues two keys:
|
|
5
|
+
* · control key (`vela_ctrl_…`) — plan, wallet, Design Plan allowance
|
|
6
|
+
* · runtime key (`sk-amr-…`) — model requests at amr-link
|
|
7
|
+
*
|
|
8
|
+
* The quota pill only needs the control key. The CLI flow is:
|
|
9
|
+
* POST /api/v1/cli/device-authorizations → deviceId, deviceSecret, code, activation URL
|
|
10
|
+
* user approves the URL in a signed-in browser
|
|
11
|
+
* POST /api/v1/cli/device-authorizations/:id/token { deviceSecret } → the keys
|
|
12
|
+
*/
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import { getJsonViaCurl, postJsonViaCurl } from './http.js';
|
|
15
|
+
|
|
16
|
+
export const CONTROL_ORIGIN = 'https://amr-api.open-design.ai';
|
|
17
|
+
export const CONTROL_API = CONTROL_ORIGIN + '/api/v1';
|
|
18
|
+
const CLI_VERSION = '0.1.4';
|
|
19
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
function headers(extra = {}) {
|
|
22
|
+
return { Origin: 'https://open-design.ai', Referer: 'https://open-design.ai/', ...extra };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function startDevice() {
|
|
26
|
+
const res = await postJsonViaCurl(CONTROL_API + '/cli/device-authorizations', {
|
|
27
|
+
cliVersion: CLI_VERSION,
|
|
28
|
+
host: os.hostname() || 'dsh',
|
|
29
|
+
profile: 'prod',
|
|
30
|
+
}, { headers: headers() });
|
|
31
|
+
if (!res.ok) throw new Error('device authorization failed: ' + res.error);
|
|
32
|
+
const d = res.data || {};
|
|
33
|
+
if (!d.deviceId || !d.deviceSecret) {
|
|
34
|
+
throw new Error('device authorization response is missing deviceId/deviceSecret');
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
deviceId: String(d.deviceId),
|
|
38
|
+
deviceSecret: String(d.deviceSecret),
|
|
39
|
+
userCode: d.userCode ? String(d.userCode) : '',
|
|
40
|
+
activationUrl: d.activationUrl ? String(d.activationUrl) : '',
|
|
41
|
+
intervalSeconds: Number(d.pollIntervalSeconds) > 0 ? Number(d.pollIntervalSeconds) : 2,
|
|
42
|
+
expiresAt: Date.parse(d.expiresAt || '') || Date.now() + 10 * 60_000,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function waitForDevice(device, { onPending } = {}) {
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (Date.now() > device.expiresAt) {
|
|
49
|
+
throw new Error('the device code expired before it was approved');
|
|
50
|
+
}
|
|
51
|
+
await sleep((device.intervalSeconds || 2) * 1000);
|
|
52
|
+
const res = await postJsonViaCurl(
|
|
53
|
+
CONTROL_API + '/cli/device-authorizations/' + encodeURIComponent(device.deviceId) + '/token',
|
|
54
|
+
{ deviceSecret: device.deviceSecret },
|
|
55
|
+
{ headers: headers() },
|
|
56
|
+
);
|
|
57
|
+
if (res.ok && res.data && res.data.controlKey && res.data.runtimeKey) {
|
|
58
|
+
return {
|
|
59
|
+
controlKey: String(res.data.controlKey),
|
|
60
|
+
runtimeKey: String(res.data.runtimeKey),
|
|
61
|
+
email: res.data.user && res.data.user.email ? String(res.data.user.email) : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const status = String((res.data && res.data.status) || '').toLowerCase();
|
|
65
|
+
if (status === 'expired' || status === 'denied' || status === 'cancelled' || status === 'canceled' || status === 'rejected') {
|
|
66
|
+
throw new Error('the device authorization was ' + status);
|
|
67
|
+
}
|
|
68
|
+
if (res.status === 401 && /invalid_device_secret/.test(String((res.data && res.data.error) || ''))) {
|
|
69
|
+
throw new Error('the device code is no longer valid');
|
|
70
|
+
}
|
|
71
|
+
if (onPending) onPending(status || 'pending');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The workspace whose allowance is shown. Explicit ref wins; otherwise ask. */
|
|
76
|
+
export async function workspaceOf(controlKey) {
|
|
77
|
+
const res = await getJsonViaCurl(CONTROL_API + '/workspaces/current', {
|
|
78
|
+
token: controlKey,
|
|
79
|
+
headers: headers(),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) throw new Error('workspace lookup failed: ' + res.error);
|
|
82
|
+
const body = res.data || {};
|
|
83
|
+
const id = body.activeWorkspaceId || body.workspaceId;
|
|
84
|
+
if (!id) throw new Error('the account has no active workspace');
|
|
85
|
+
return String(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run the full device login. `onDevice` receives the URL/code to show the user. */
|
|
89
|
+
export async function loginOpenDesign({ onDevice, onPending } = {}) {
|
|
90
|
+
const device = await startDevice();
|
|
91
|
+
if (onDevice) onDevice(device);
|
|
92
|
+
const keys = await waitForDevice(device, { onPending });
|
|
93
|
+
const workspaceId = await workspaceOf(keys.controlKey);
|
|
94
|
+
return { ...keys, workspaceId };
|
|
95
|
+
}
|