@hystudio/dsh-quota-pill 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +3 -3
- package/index.js +14 -3
- package/package.json +1 -1
- package/src/adapters-deepseek.js +3 -114
- package/src/catalog.js +35 -0
- package/src/dsh.js +138 -0
- package/src/server.js +42 -2
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 {
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which providers this user actually has.
|
|
3
|
+
*
|
|
4
|
+
* A hardcoded adapter list is wrong in both directions: it lists providers that
|
|
5
|
+
* were removed or never configured, and it cannot know about new ones. DSH's own
|
|
6
|
+
* `session/modelCatalog` is the authoritative answer — it is literally the set of
|
|
7
|
+
* providers the app can route to right now.
|
|
8
|
+
*
|
|
9
|
+
* If the catalog is unavailable (DSH not running), callers fall back to showing
|
|
10
|
+
* whatever the adapters can read, so the pill degrades rather than going blank.
|
|
11
|
+
*/
|
|
12
|
+
import { dshRpc } from './dsh.js';
|
|
13
|
+
|
|
14
|
+
const TTL_MS = 120 * 1000;
|
|
15
|
+
let cache = { at: 0, ids: null };
|
|
16
|
+
|
|
17
|
+
/** Provider ids DSH currently exposes, or null when unknown. */
|
|
18
|
+
export async function catalogProviderIds() {
|
|
19
|
+
if (cache.ids && Date.now() - cache.at < TTL_MS) return cache.ids;
|
|
20
|
+
|
|
21
|
+
const res = await dshRpc('session/modelCatalog', {});
|
|
22
|
+
if (!res.ok) return cache.ids; // keep the last good answer, if any
|
|
23
|
+
|
|
24
|
+
const groups = (res.value && res.value.groups) || [];
|
|
25
|
+
const ids = groups.map((g) => g && g.id).filter(Boolean);
|
|
26
|
+
if (!ids.length) return cache.ids;
|
|
27
|
+
|
|
28
|
+
cache = { at: Date.now(), ids };
|
|
29
|
+
return ids;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Drop the cached catalog (tests, or after installing a provider). */
|
|
33
|
+
export function forgetCatalog() {
|
|
34
|
+
cache = { at: 0, ids: null };
|
|
35
|
+
}
|
package/src/dsh.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to the running DSH instance.
|
|
3
|
+
*
|
|
4
|
+
* Two things need it:
|
|
5
|
+
* · the DeepSeek balance, which only DSH can read (it holds the Platform grant)
|
|
6
|
+
* · the provider catalog, which says which providers this user actually has
|
|
7
|
+
*
|
|
8
|
+
* Both go through DSH's own RPC, authenticated with a signed session cookie:
|
|
9
|
+
* name = "dsh-auth-" + base64url(sha256(authority))
|
|
10
|
+
* value = "v1." + base64url({version, authority, issuedAt, expiresAt}) + "." + sig
|
|
11
|
+
* `issuedAt` / `expiresAt` are milliseconds and `version` must be the number 1 —
|
|
12
|
+
* the wire codec rejects anything else, and a bad payload fails as 401 even
|
|
13
|
+
* though the signature itself verifies.
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { URL } from 'node:url';
|
|
18
|
+
import { browserSessionSecret } from './credentials.js';
|
|
19
|
+
|
|
20
|
+
const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every loopback port a DSH-named process is listening on.
|
|
24
|
+
*
|
|
25
|
+
* A single guess is not enough: DSH's helper processes also open debugging
|
|
26
|
+
* ports, and picking one of those makes every RPC fail with 404.
|
|
27
|
+
*/
|
|
28
|
+
function candidatePorts() {
|
|
29
|
+
const out = [];
|
|
30
|
+
const explicit = process.env.DSH_RPC_BASE || process.env.QUOTA_PILL_DSH_BASE;
|
|
31
|
+
if (explicit) out.push(explicit.replace(/\/$/, ''));
|
|
32
|
+
if (process.env.QUOTA_PILL_DSH_PORT) out.push(`http://127.0.0.1:${process.env.QUOTA_PILL_DSH_PORT}`);
|
|
33
|
+
try {
|
|
34
|
+
const text = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
timeout: 4000,
|
|
37
|
+
});
|
|
38
|
+
for (const line of text.split('\n')) {
|
|
39
|
+
if (!/DeepSeek|dsh/i.test(line)) continue;
|
|
40
|
+
const m = /127\.0\.0\.1:(\d+)/.exec(line);
|
|
41
|
+
if (!m) continue;
|
|
42
|
+
const base = `http://127.0.0.1:${m[1]}`;
|
|
43
|
+
if (!out.includes(base)) out.push(base);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
/* lsof unavailable — env vars are the only path */
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function signCookie(base, secretB64) {
|
|
52
|
+
const authority = new URL(base).host;
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = Buffer.from(secretB64, 'base64url');
|
|
56
|
+
} catch {
|
|
57
|
+
raw = Buffer.from(secretB64, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
const name = 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest());
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const payload = { version: 1, authority, issuedAt: now, expiresAt: now + 3600 * 1000 };
|
|
62
|
+
const body = b64u(JSON.stringify(payload));
|
|
63
|
+
const sig = b64u(crypto.createHmac('sha256', raw).update(body).digest());
|
|
64
|
+
return `${name}=v1.${body}.${sig}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** POST one RPC to a specific base. */
|
|
68
|
+
async function rpcAt(base, method, args, cookie, timeoutMs = 12000) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(base + '/api/' + method.replace(/\./g, '/'), {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Cookie: cookie },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
type: 'client-request',
|
|
77
|
+
rpcId: 'quota-pill-' + method,
|
|
78
|
+
method,
|
|
79
|
+
payload: { args },
|
|
80
|
+
}),
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
});
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
if (!res.ok) return { ok: false, status: res.status, error: `http ${res.status}` };
|
|
85
|
+
let env;
|
|
86
|
+
try {
|
|
87
|
+
env = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, error: 'unparsable json' };
|
|
90
|
+
}
|
|
91
|
+
const result = (env && env.result) || {};
|
|
92
|
+
if (result.ok === false) {
|
|
93
|
+
const e = result.error || {};
|
|
94
|
+
return { ok: false, error: `${e.code || 'rpc-failed'}: ${e.message || ''}`.trim() };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, value: result.value };
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return { ok: false, error: String((e && e.message) || e) };
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Call one RPC against whichever candidate port actually answers.
|
|
106
|
+
*
|
|
107
|
+
* Ports are probed with a cheap, always-present RPC rather than trusted by
|
|
108
|
+
* order, and the winner is remembered so we do not re-probe every call.
|
|
109
|
+
*/
|
|
110
|
+
let knownBase = null;
|
|
111
|
+
|
|
112
|
+
export async function dshRpc(method, args) {
|
|
113
|
+
const secret = browserSessionSecret();
|
|
114
|
+
if (!secret) return { ok: false, error: 'no browser-session secret in credentials' };
|
|
115
|
+
|
|
116
|
+
const bases = [];
|
|
117
|
+
if (knownBase) bases.push(knownBase);
|
|
118
|
+
for (const b of candidatePorts()) if (!bases.includes(b)) bases.push(b);
|
|
119
|
+
|
|
120
|
+
let lastError = 'no DSH port responded';
|
|
121
|
+
for (const base of bases) {
|
|
122
|
+
const cookie = signCookie(base, secret);
|
|
123
|
+
const res = await rpcAt(base, method, args, cookie);
|
|
124
|
+
// A 404 means this port is a different HTTP surface (a helper's debug port):
|
|
125
|
+
// remember only ports that actually serve the RPC routes.
|
|
126
|
+
if (res.ok || (res.status && res.status !== 404)) {
|
|
127
|
+
knownBase = base;
|
|
128
|
+
return res;
|
|
129
|
+
}
|
|
130
|
+
lastError = res.error || lastError;
|
|
131
|
+
}
|
|
132
|
+
return { ok: false, error: lastError };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reset the remembered port (tests, or after DSH restarts on a new port). */
|
|
136
|
+
export function forgetDshBase() {
|
|
137
|
+
knownBase = null;
|
|
138
|
+
}
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { kimi, opencode, commandcode } from './adapters-apikey.js';
|
|
|
24
24
|
import { xai, codex, agy } from './adapters-cliproxy.js';
|
|
25
25
|
import { deepseek } from './adapters-deepseek.js';
|
|
26
26
|
import { credentialSource, hasCliProxy, CLI_PROXY_DIR, CREDENTIALS_FILE } from './credentials.js';
|
|
27
|
+
import { catalogProviderIds } from './catalog.js';
|
|
27
28
|
|
|
28
29
|
const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
|
|
29
30
|
const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
|
|
@@ -101,11 +102,50 @@ async function readOne(provider, model, { fresh = false } = {}) {
|
|
|
101
102
|
return { ...payload, cached: false, at: now };
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Which DSH catalog ids count as "this adapter".
|
|
107
|
+
*
|
|
108
|
+
* DSH names providers after the upstream (`antigravity`, `deepseek-account`,
|
|
109
|
+
* `opencode-go`); the adapters use the short keys the pill has always used.
|
|
110
|
+
*/
|
|
111
|
+
const CATALOG_ALIASES = {
|
|
112
|
+
agy: ['antigravity', 'agy'],
|
|
113
|
+
deepseek: ['deepseek-account', 'deepseek'],
|
|
114
|
+
opencode: ['opencode-go', 'opencode'],
|
|
115
|
+
kimi: ['kimi-coding', 'kimi'],
|
|
116
|
+
xai: ['xai'],
|
|
117
|
+
codex: ['codex'],
|
|
118
|
+
commandcode: ['commandcode'],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function inCatalog(provider, ids) {
|
|
122
|
+
if (!ids) return true; // catalog unknown: do not hide anything
|
|
123
|
+
const names = CATALOG_ALIASES[provider] || [provider];
|
|
124
|
+
return names.some((n) => ids.includes(n));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every provider worth showing: present in DSH's catalog AND actually readable.
|
|
129
|
+
*
|
|
130
|
+
* Two filters, for two different complaints:
|
|
131
|
+
* · catalog — a provider removed from DSH must disappear from the pill, even
|
|
132
|
+
* though its adapter still exists in this package
|
|
133
|
+
* · kind 'none' — a provider with no credential has nothing to show; listing
|
|
134
|
+
* it as "no data source" is noise, and new users mistake it for breakage
|
|
135
|
+
*
|
|
136
|
+
* `probe-failed` deliberately still shows: the provider *is* configured, so a
|
|
137
|
+
* failure is information the user needs.
|
|
138
|
+
*/
|
|
104
139
|
async function readAll({ fresh = false } = {}) {
|
|
140
|
+
const ids = await catalogProviderIds();
|
|
141
|
+
const wanted = Object.keys(ADAPTERS).filter((p) => inCatalog(p, ids));
|
|
142
|
+
|
|
105
143
|
const entries = await Promise.all(
|
|
106
|
-
|
|
144
|
+
wanted.map(async (p) => [p, await readOne(p, null, { fresh })]),
|
|
145
|
+
);
|
|
146
|
+
return Object.fromEntries(
|
|
147
|
+
entries.filter(([, v]) => v && v.kind !== 'none'),
|
|
107
148
|
);
|
|
108
|
-
return Object.fromEntries(entries);
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
/** What is configured and what is missing — powers the agent-driven setup. */
|