u1s1-cli 1.3.0 → 1.3.2
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/dist/api.d.ts +8 -0
- package/dist/api.js +125 -21
- package/dist/bench.js +23 -6
- package/dist/deploy.d.ts +6 -0
- package/dist/deploy.js +41 -9
- package/dist/device-auth.d.ts +32 -2
- package/dist/device-auth.js +149 -31
- package/dist/index.js +18 -4
- package/dist/login.js +49 -15
- package/dist/model.d.ts +6 -0
- package/dist/model.js +24 -5
- package/dist/search-tools.js +15 -2
- package/dist/subagent.d.ts +14 -0
- package/dist/subagent.js +47 -11
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +96 -19
- package/dist/update.js +19 -7
- package/dist/web.js +14 -2
- package/dist/workflow/runner.d.ts +17 -0
- package/dist/workflow/runner.js +6 -7
- package/dist/workflow/templates.js +4 -4
- package/package.json +1 -1
package/dist/device-auth.js
CHANGED
|
@@ -2,7 +2,14 @@ import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { VERSION } from "./config.js";
|
|
4
4
|
const enc = new TextEncoder();
|
|
5
|
+
const SIGNING_PROXY_REQUEST_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
5
6
|
let cachedPrivate;
|
|
7
|
+
class SigningProxyRequestTooLargeError extends Error {
|
|
8
|
+
constructor(maxBytes) {
|
|
9
|
+
super(`local signing proxy request exceeds ${maxBytes} bytes`);
|
|
10
|
+
this.name = "SigningProxyRequestTooLargeError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
function b64url(value) {
|
|
7
14
|
const bytes = typeof value === "string" ? enc.encode(value) : value;
|
|
8
15
|
return Buffer.from(bytes).toString("base64url");
|
|
@@ -86,7 +93,98 @@ function requestHeaders(input) {
|
|
|
86
93
|
}
|
|
87
94
|
return headers;
|
|
88
95
|
}
|
|
96
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
97
|
+
export async function readSigningProxyRequestBody(request, maxBytes = SIGNING_PROXY_REQUEST_LIMIT_BYTES) {
|
|
98
|
+
const declaredLength = Number(request.headers["content-length"]);
|
|
99
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
100
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
101
|
+
}
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let total = 0;
|
|
104
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
105
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
106
|
+
total += bytes.byteLength;
|
|
107
|
+
if (total > maxBytes)
|
|
108
|
+
throw new SigningProxyRequestTooLargeError(maxBytes);
|
|
109
|
+
chunks.push(bytes);
|
|
110
|
+
}
|
|
111
|
+
return chunks.length ? Buffer.concat(chunks, total) : undefined;
|
|
112
|
+
}
|
|
113
|
+
// Refresh once we are within a day of the server-side 7-day expiry; back off for
|
|
114
|
+
// a while after a failed refresh so a flaky Gateway is not hammered per request.
|
|
115
|
+
const ATTESTATION_REFRESH_MARGIN_MS = 24 * 60 * 60 * 1000;
|
|
116
|
+
const ATTESTATION_REFRESH_COOLDOWN_MS = 30_000;
|
|
117
|
+
// Only ever block a request while we have no token at all, and even then only
|
|
118
|
+
// briefly — a Gateway too slow to answer would fail the real request anyway.
|
|
119
|
+
const ATTESTATION_BLOCK_TIMEOUT_MS = 4_000;
|
|
89
120
|
let signingProxy;
|
|
121
|
+
function unrefDelay(ms) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const timer = setTimeout(resolve, ms);
|
|
124
|
+
if (typeof timer.unref === "function")
|
|
125
|
+
timer.unref();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function applyAttestation(holder, result) {
|
|
129
|
+
if (typeof result.token !== "string" || result.token.length === 0 || result.token.length > 1024)
|
|
130
|
+
return;
|
|
131
|
+
holder.token = result.token;
|
|
132
|
+
holder.expiresAtMs = typeof result.expiresInSeconds === "number" && result.expiresInSeconds > 0
|
|
133
|
+
? Date.now() + result.expiresInSeconds * 1000
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
/** Seed the mutable holder from a new ensureSigningProxy call without dropping a fresher token. */
|
|
137
|
+
function updateAttestationHolder(holder, source) {
|
|
138
|
+
if (!source)
|
|
139
|
+
return;
|
|
140
|
+
if (source.refresh)
|
|
141
|
+
holder.refresh = source.refresh;
|
|
142
|
+
if (source.token && !holder.token) {
|
|
143
|
+
applyAttestation(holder, { token: source.token, expiresInSeconds: source.expiresInSeconds });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Kick off a single-flight refresh when the token is missing or nearing expiry.
|
|
148
|
+
* Returns the in-flight refresh promise (or undefined when no refresh is due),
|
|
149
|
+
* so the caller can decide whether to wait for it.
|
|
150
|
+
*/
|
|
151
|
+
function ensureFreshAttestation(holder) {
|
|
152
|
+
if (!holder.refresh)
|
|
153
|
+
return undefined;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const stale = !holder.token
|
|
156
|
+
|| (holder.expiresAtMs !== undefined && now >= holder.expiresAtMs - ATTESTATION_REFRESH_MARGIN_MS);
|
|
157
|
+
if (!stale)
|
|
158
|
+
return undefined;
|
|
159
|
+
if (holder.refreshing)
|
|
160
|
+
return holder.refreshing;
|
|
161
|
+
if (holder.lastFailureMs !== undefined && now - holder.lastFailureMs < ATTESTATION_REFRESH_COOLDOWN_MS) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const run = (async () => {
|
|
165
|
+
try {
|
|
166
|
+
applyAttestation(holder, await holder.refresh());
|
|
167
|
+
holder.lastFailureMs = undefined;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
holder.lastFailureMs = Date.now();
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
holder.refreshing = undefined;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
holder.refreshing = run;
|
|
177
|
+
return run;
|
|
178
|
+
}
|
|
179
|
+
/** Attach a fresh (self-healing) attestation header to an outbound proxy request. */
|
|
180
|
+
export async function attachAttestationHeader(holder, headers) {
|
|
181
|
+
const refreshing = ensureFreshAttestation(holder);
|
|
182
|
+
if (refreshing && !holder.token) {
|
|
183
|
+
await Promise.race([refreshing, unrefDelay(ATTESTATION_BLOCK_TIMEOUT_MS)]);
|
|
184
|
+
}
|
|
185
|
+
if (holder.token)
|
|
186
|
+
headers.set("x-u1s1-attestation", holder.token);
|
|
187
|
+
}
|
|
90
188
|
function clientSurface(fallback) {
|
|
91
189
|
const explicit = process.env["U1S1_CLIENT"];
|
|
92
190
|
if (explicit === "terminal" || explicit === "web" || explicit === "desktop" || explicit === "cloud") {
|
|
@@ -98,14 +196,17 @@ function clientSurface(fallback) {
|
|
|
98
196
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
99
197
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
100
198
|
*/
|
|
101
|
-
export async function ensureSigningProxy(cfg, fallbackClient = "terminal",
|
|
199
|
+
export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource) {
|
|
102
200
|
if (!hasDeviceCredential(cfg))
|
|
103
201
|
throw new Error("当前安装需要重新登录,以创建设备凭证");
|
|
104
202
|
const client = clientSurface(fallbackClient);
|
|
105
203
|
const current = signingProxy;
|
|
106
|
-
if (current && current.token === cfg.deviceToken && current.client === client
|
|
107
|
-
|
|
204
|
+
if (current && current.token === cfg.deviceToken && current.client === client) {
|
|
205
|
+
updateAttestationHolder(current.attestation, attestationSource);
|
|
108
206
|
return current;
|
|
207
|
+
}
|
|
208
|
+
const attestation = {};
|
|
209
|
+
updateAttestationHolder(attestation, attestationSource);
|
|
109
210
|
const localKey = `local-${randomBytes(32).toString("hex")}`;
|
|
110
211
|
const upstreamOrigin = new URL(cfg.baseUrl).origin;
|
|
111
212
|
const server = createServer(async (req, res) => {
|
|
@@ -123,37 +224,54 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clien
|
|
|
123
224
|
res.end(JSON.stringify({ error: { message: "signing proxy only serves /v1/*" } }));
|
|
124
225
|
return;
|
|
125
226
|
}
|
|
227
|
+
const upstreamAbort = new AbortController();
|
|
228
|
+
const abortUpstream = () => {
|
|
229
|
+
if (!res.writableEnded) {
|
|
230
|
+
upstreamAbort.abort(new Error("local signing proxy client disconnected"));
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
res.once("close", abortUpstream);
|
|
126
234
|
const target = new URL(`${localUrl.pathname}${localUrl.search}`, upstreamOrigin).toString();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
235
|
+
try {
|
|
236
|
+
const body = await readSigningProxyRequestBody(req);
|
|
237
|
+
const outboundHeaders = requestHeaders(req.headers);
|
|
238
|
+
// Set these at the final local hop so model calls and extension tools share
|
|
239
|
+
// exactly the same attribution, regardless of their upstream SDK defaults.
|
|
240
|
+
outboundHeaders.set("x-u1s1-client", client);
|
|
241
|
+
outboundHeaders.set("x-u1s1-version", VERSION);
|
|
242
|
+
outboundHeaders.set("x-u1s1-platform", `${process.platform}-${process.arch}`);
|
|
243
|
+
await attachAttestationHeader(attestation, outboundHeaders);
|
|
244
|
+
const upstream = await authorizedFetch(cfg, target, {
|
|
245
|
+
method: req.method ?? "GET",
|
|
246
|
+
headers: outboundHeaders,
|
|
247
|
+
body,
|
|
248
|
+
signal: upstreamAbort.signal,
|
|
249
|
+
});
|
|
250
|
+
const headers = {};
|
|
251
|
+
upstream.headers.forEach((value, name) => {
|
|
252
|
+
if (!["content-length", "transfer-encoding", "connection"].includes(name))
|
|
253
|
+
headers[name] = value;
|
|
254
|
+
});
|
|
255
|
+
res.writeHead(upstream.status, headers);
|
|
256
|
+
if (upstream.body) {
|
|
257
|
+
for await (const chunk of upstream.body)
|
|
258
|
+
res.write(chunk);
|
|
259
|
+
}
|
|
260
|
+
res.end();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
res.off("close", abortUpstream);
|
|
153
264
|
}
|
|
154
|
-
res.end();
|
|
155
265
|
}
|
|
156
266
|
catch (error) {
|
|
267
|
+
if (error instanceof SigningProxyRequestTooLargeError && !res.destroyed) {
|
|
268
|
+
req.resume();
|
|
269
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
270
|
+
res.end(JSON.stringify({ error: { message: "local signing proxy request body is too large" } }));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (res.destroyed)
|
|
274
|
+
return;
|
|
157
275
|
if (res.headersSent)
|
|
158
276
|
return void res.destroy(error);
|
|
159
277
|
res.writeHead(502, { "content-type": "application/json" });
|
|
@@ -174,7 +292,7 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", clien
|
|
|
174
292
|
localKey,
|
|
175
293
|
token: cfg.deviceToken,
|
|
176
294
|
client,
|
|
177
|
-
|
|
295
|
+
attestation,
|
|
178
296
|
};
|
|
179
297
|
return signingProxy;
|
|
180
298
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { registerLoopCommand } from "./loop.js";
|
|
|
8
8
|
import { ensureSearchTools } from "./search-tools.js";
|
|
9
9
|
import { ensureUsableShell } from "./shell-doctor.js";
|
|
10
10
|
import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
|
|
11
|
-
import { AuthError, fetchModels, loadCustomEndpoints } from "./api.js";
|
|
11
|
+
import { AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "./api.js";
|
|
12
12
|
import { ensureSigningProxy } from "./device-auth.js";
|
|
13
13
|
const PACKAGE_NAME = "u1s1-cli";
|
|
14
14
|
/** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
|
|
@@ -32,8 +32,12 @@ async function checkForUpdate() {
|
|
|
32
32
|
signal: AbortSignal.timeout(5_000),
|
|
33
33
|
});
|
|
34
34
|
if (res.ok) {
|
|
35
|
-
const body =
|
|
36
|
-
latest = body.version
|
|
35
|
+
const body = await readJsonResponseCapped(res, 64 * 1024);
|
|
36
|
+
latest = typeof body.version === "string"
|
|
37
|
+
&& body.version.length <= 100
|
|
38
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(body.version)
|
|
39
|
+
? body.version
|
|
40
|
+
: undefined;
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
catch {
|
|
@@ -193,7 +197,17 @@ async function runAgent(cfg, args) {
|
|
|
193
197
|
// it after the live model list arrives; explicit user choices remain intact.
|
|
194
198
|
ensureDefaultSettings(MODELS);
|
|
195
199
|
// pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
|
|
196
|
-
const signing = await ensureSigningProxy(cfg, "terminal",
|
|
200
|
+
const signing = await ensureSigningProxy(cfg, "terminal", {
|
|
201
|
+
token: modelsResp?.clientAttestation,
|
|
202
|
+
expiresInSeconds: modelsResp?.clientAttestationExpiresInSeconds,
|
|
203
|
+
refresh: async () => {
|
|
204
|
+
const refreshed = await fetchModels(cfg);
|
|
205
|
+
return {
|
|
206
|
+
token: refreshed.clientAttestation,
|
|
207
|
+
expiresInSeconds: refreshed.clientAttestationExpiresInSeconds,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
});
|
|
197
211
|
const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
|
|
198
212
|
ensureBrandPrompt(await shellReady);
|
|
199
213
|
ensureProviderModels(officialCfg);
|
package/dist/login.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { hostname, platform } from "node:os";
|
|
3
|
-
import { fetchMe } from "./api.js";
|
|
3
|
+
import { fetchMe, readJsonResponseCapped } from "./api.js";
|
|
4
4
|
import { printConsoleBanner } from "./brand.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { generateDeviceKeyPair, hasDeviceCredential } from "./device-auth.js";
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const VERSION = require("../package.json").version;
|
|
9
|
+
const MAX_DEVICE_AUTH_RESPONSE_BYTES = 64 * 1024;
|
|
10
|
+
function boundedInteger(value, fallback, min, max) {
|
|
11
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max
|
|
12
|
+
? value
|
|
13
|
+
: fallback;
|
|
14
|
+
}
|
|
15
|
+
function boundedCredential(value, prefix) {
|
|
16
|
+
return typeof value === "string"
|
|
17
|
+
&& value.startsWith(prefix)
|
|
18
|
+
&& value.length <= 4_096
|
|
19
|
+
&& !/[\u0000-\u001f\u007f]/.test(value)
|
|
20
|
+
? value
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
9
23
|
function tryOpenBrowser(url) {
|
|
10
24
|
import("node:child_process")
|
|
11
25
|
.then(({ spawn }) => {
|
|
@@ -36,16 +50,26 @@ export async function startDeviceLogin(origin) {
|
|
|
36
50
|
}),
|
|
37
51
|
signal: AbortSignal.timeout(15_000),
|
|
38
52
|
});
|
|
39
|
-
if (!resp.ok)
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
void resp.body?.cancel("device login start rejected").catch(() => { });
|
|
40
55
|
return null;
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
}
|
|
57
|
+
const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
|
|
58
|
+
if (typeof data.verify_url !== "string"
|
|
59
|
+
|| data.verify_url.length > 2_048
|
|
60
|
+
|| typeof data.poll_secret !== "string"
|
|
61
|
+
|| !data.poll_secret
|
|
62
|
+
|| data.poll_secret.length > 4_096
|
|
63
|
+
|| /[\u0000-\u001f\u007f]/.test(data.poll_secret))
|
|
64
|
+
return null;
|
|
65
|
+
const verifyUrl = new URL(data.verify_url);
|
|
66
|
+
if (!["http:", "https:"].includes(verifyUrl.protocol) || verifyUrl.username || verifyUrl.password)
|
|
43
67
|
return null;
|
|
44
68
|
return {
|
|
45
|
-
verify_url:
|
|
69
|
+
verify_url: verifyUrl.toString(),
|
|
46
70
|
poll_secret: data.poll_secret,
|
|
47
|
-
interval: data.interval
|
|
48
|
-
expires_in: data.expires_in
|
|
71
|
+
interval: boundedInteger(data.interval, 2, 1, 30),
|
|
72
|
+
expires_in: boundedInteger(data.expires_in, 900, 1, 1_800),
|
|
49
73
|
private_jwk: pair.privateJwk,
|
|
50
74
|
public_jwk: pair.publicJwk,
|
|
51
75
|
};
|
|
@@ -56,9 +80,11 @@ export async function startDeviceLogin(origin) {
|
|
|
56
80
|
}
|
|
57
81
|
/** 轮询等浏览器批准;只接受带设备凭证的新网关响应。outcome 可带出失败原因。 */
|
|
58
82
|
export async function pollDeviceLogin(origin, start, outcome) {
|
|
59
|
-
const
|
|
83
|
+
const interval = boundedInteger(start.interval, 2, 1, 30);
|
|
84
|
+
const expiresIn = boundedInteger(start.expires_in, 900, 1, 1_800);
|
|
85
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
60
86
|
while (Date.now() < deadline) {
|
|
61
|
-
await sleep(
|
|
87
|
+
await sleep(interval * 1000);
|
|
62
88
|
try {
|
|
63
89
|
const resp = await fetch(`${origin}/auth/device/poll`, {
|
|
64
90
|
method: "POST",
|
|
@@ -66,14 +92,22 @@ export async function pollDeviceLogin(origin, start, outcome) {
|
|
|
66
92
|
body: JSON.stringify({ poll_secret: start.poll_secret }),
|
|
67
93
|
signal: AbortSignal.timeout(10_000),
|
|
68
94
|
});
|
|
69
|
-
if (!resp.ok)
|
|
95
|
+
if (!resp.ok) {
|
|
96
|
+
void resp.body?.cancel("device login poll rejected").catch(() => { });
|
|
70
97
|
continue;
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
}
|
|
99
|
+
const data = await readJsonResponseCapped(resp, MAX_DEVICE_AUTH_RESPONSE_BYTES);
|
|
100
|
+
const apiKey = boundedCredential(data.api_key, "u1s1-");
|
|
101
|
+
const deviceToken = boundedCredential(data.device_token, "u1s1d-");
|
|
102
|
+
if (data.status === "ok" && apiKey && deviceToken) {
|
|
73
103
|
return {
|
|
74
|
-
apiKey
|
|
75
|
-
deviceToken
|
|
76
|
-
deviceId: data.device_id
|
|
104
|
+
apiKey,
|
|
105
|
+
deviceToken,
|
|
106
|
+
deviceId: typeof data.device_id === "number"
|
|
107
|
+
&& Number.isSafeInteger(data.device_id)
|
|
108
|
+
&& data.device_id > 0
|
|
109
|
+
? data.device_id
|
|
110
|
+
: undefined,
|
|
77
111
|
devicePrivateJwk: start.private_jwk,
|
|
78
112
|
devicePublicJwk: start.public_jwk,
|
|
79
113
|
};
|
package/dist/model.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
+
import { type ModelsResponse } from "./api.js";
|
|
2
|
+
import { type CliConfig } from "./config.js";
|
|
3
|
+
type ModelFetcher = (cfg: CliConfig) => Promise<ModelsResponse>;
|
|
4
|
+
/** Refresh official models while preserving the built-in list as an explicit offline fallback. */
|
|
5
|
+
export declare function refreshOfficialModels(cfg: CliConfig, fetcher?: ModelFetcher): Promise<string | null>;
|
|
1
6
|
export declare function modelCommand(nameOrAlias?: string): Promise<void>;
|
|
7
|
+
export {};
|
package/dist/model.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
import { loadCustomEndpoints } from "./api.js";
|
|
2
|
-
import { CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, } from "./config.js";
|
|
1
|
+
import { fetchModels, loadCustomEndpoints } from "./api.js";
|
|
2
|
+
import { apiModelToDef, CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, setModelsFromApi, } from "./config.js";
|
|
3
|
+
/** Refresh official models while preserving the built-in list as an explicit offline fallback. */
|
|
4
|
+
export async function refreshOfficialModels(cfg, fetcher = fetchModels) {
|
|
5
|
+
try {
|
|
6
|
+
const { models } = await fetcher(cfg);
|
|
7
|
+
if (models.length === 0)
|
|
8
|
+
return "线上模型列表为空";
|
|
9
|
+
setModelsFromApi(models.map(apiModelToDef));
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
3
16
|
export async function modelCommand(nameOrAlias) {
|
|
4
17
|
const cfg = loadConfig();
|
|
5
|
-
//
|
|
6
|
-
await
|
|
18
|
+
// Fetch both live catalogues before resolving the persisted default.
|
|
19
|
+
const [officialModelsError] = await Promise.all([
|
|
20
|
+
refreshOfficialModels(cfg),
|
|
21
|
+
loadCustomEndpoints(cfg),
|
|
22
|
+
]);
|
|
23
|
+
if (officialModelsError) {
|
|
24
|
+
console.error(` 获取线上模型列表失败,使用内置列表:${officialModelsError}`);
|
|
25
|
+
}
|
|
7
26
|
const current = resolvePreferredModel(cfg);
|
|
8
27
|
if (!nameOrAlias) {
|
|
9
28
|
console.log("");
|
|
@@ -26,7 +45,7 @@ export async function modelCommand(nameOrAlias) {
|
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
console.log("");
|
|
29
|
-
console.log(" 切换:u1s1 model
|
|
48
|
+
console.log(" 切换:u1s1 model <model-id>(对话里 /model 同样会记住)");
|
|
30
49
|
if (CUSTOM_ENDPOINTS.length) {
|
|
31
50
|
console.log(" 端点模型重名时可加限定:u1s1 model 端点名:模型id");
|
|
32
51
|
}
|
package/dist/search-tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
3
3
|
import { arch, platform } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { Readable } from "node:stream";
|
|
5
|
+
import { Readable, Transform } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { agentDir } from "./config.js";
|
|
8
8
|
/**
|
|
@@ -24,6 +24,7 @@ const TOOLS = [
|
|
|
24
24
|
];
|
|
25
25
|
const binDir = join(agentDir, "bin");
|
|
26
26
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
27
|
+
const MAX_TOOL_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
27
28
|
function archStr() {
|
|
28
29
|
const a = arch();
|
|
29
30
|
return a === "arm64" ? "aarch64" : a === "x64" ? "x86_64" : null;
|
|
@@ -110,11 +111,23 @@ async function install(tool, baseUrl, binName) {
|
|
|
110
111
|
});
|
|
111
112
|
if (!res.ok || !res.body)
|
|
112
113
|
throw new Error(`HTTP ${res.status}`);
|
|
114
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
115
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_TOOL_ARCHIVE_BYTES) {
|
|
116
|
+
void res.body.cancel("tool archive exceeded limit").catch(() => { });
|
|
117
|
+
throw new Error(`下载文件超过 ${MAX_TOOL_ARCHIVE_BYTES} bytes`);
|
|
118
|
+
}
|
|
113
119
|
const archive = join(binDir, asset);
|
|
114
120
|
// fd/rg 并行安装,解压目录按工具名+pid 隔离,避免互踩
|
|
115
121
|
const extractDir = join(binDir, `preseed_${tool.bin}_${process.pid}`);
|
|
116
122
|
try {
|
|
117
|
-
|
|
123
|
+
let received = 0;
|
|
124
|
+
const limiter = new Transform({
|
|
125
|
+
transform(chunk, _encoding, callback) {
|
|
126
|
+
received += chunk.byteLength;
|
|
127
|
+
callback(received > MAX_TOOL_ARCHIVE_BYTES ? new Error("tool archive exceeded limit") : null, received > MAX_TOOL_ARCHIVE_BYTES ? undefined : chunk);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
await pipeline(Readable.fromWeb(res.body), limiter, createWriteStream(archive));
|
|
118
131
|
mkdirSync(extractDir, { recursive: true });
|
|
119
132
|
if (!extract(archive, extractDir))
|
|
120
133
|
throw new Error(`解压失败 ${asset}`);
|
package/dist/subagent.d.ts
CHANGED
|
@@ -30,6 +30,19 @@ export interface SubagentOutcome {
|
|
|
30
30
|
text: string;
|
|
31
31
|
usage: SubagentUsage;
|
|
32
32
|
}
|
|
33
|
+
type PromptSession = {
|
|
34
|
+
prompt(task: string): Promise<void>;
|
|
35
|
+
abort(): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Run one prompt while preserving the reason that stopped it. AgentSession.abort()
|
|
39
|
+
* resolves the active prompt normally, so the caller must not infer success merely
|
|
40
|
+
* because prompt() settled after a timeout or parent cancellation.
|
|
41
|
+
*/
|
|
42
|
+
export declare function promptSubagentSession(session: PromptSession, task: string, options: {
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}): Promise<void>;
|
|
33
46
|
/**
|
|
34
47
|
* Spawn 一个独立上下文的子 agent 执行任务,返回其最终文本输出。
|
|
35
48
|
* 模型解析:"provider/id" 精确匹配;裸 id(如 deepseek-v4-flash)先按原样找,
|
|
@@ -47,3 +60,4 @@ export declare function runPool<T>(input: {
|
|
|
47
60
|
run: (item: T, index: number) => Promise<void>;
|
|
48
61
|
signal?: AbortSignal;
|
|
49
62
|
}): Promise<void>;
|
|
63
|
+
export {};
|
package/dist/subagent.js
CHANGED
|
@@ -6,6 +6,45 @@ export const SUBAGENT_CONCURRENCY = 4;
|
|
|
6
6
|
export const SUBAGENT_TIMEOUT_MS = 15 * 60_000;
|
|
7
7
|
/** ModelRuntime 进程内只建一次(auth/models 解析有启动开销)。 */
|
|
8
8
|
let sharedModelRuntime;
|
|
9
|
+
function cancelledError(signal) {
|
|
10
|
+
return new Error("已取消", { cause: signal.reason });
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run one prompt while preserving the reason that stopped it. AgentSession.abort()
|
|
14
|
+
* resolves the active prompt normally, so the caller must not infer success merely
|
|
15
|
+
* because prompt() settled after a timeout or parent cancellation.
|
|
16
|
+
*/
|
|
17
|
+
export async function promptSubagentSession(session, task, options) {
|
|
18
|
+
if (options.signal?.aborted)
|
|
19
|
+
throw cancelledError(options.signal);
|
|
20
|
+
let timedOut = false;
|
|
21
|
+
let promptError;
|
|
22
|
+
const abortSession = () => {
|
|
23
|
+
void session.abort().catch(() => { });
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
timedOut = true;
|
|
27
|
+
abortSession();
|
|
28
|
+
}, options.timeoutMs);
|
|
29
|
+
const onParentAbort = abortSession;
|
|
30
|
+
options.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
31
|
+
try {
|
|
32
|
+
await session.prompt(task);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
promptError = error;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
options.signal?.removeEventListener("abort", onParentAbort);
|
|
40
|
+
}
|
|
41
|
+
if (options.signal?.aborted)
|
|
42
|
+
throw cancelledError(options.signal);
|
|
43
|
+
if (timedOut)
|
|
44
|
+
throw new Error(`子任务超时(${options.timeoutMs}ms)`, { cause: promptError });
|
|
45
|
+
if (promptError)
|
|
46
|
+
throw promptError;
|
|
47
|
+
}
|
|
9
48
|
/** 从会话消息里取最后一条非空 assistant 文本作为子 agent 的最终输出。 */
|
|
10
49
|
function extractFinalText(messages) {
|
|
11
50
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -59,6 +98,8 @@ function extractUsage(messages, model) {
|
|
|
59
98
|
* 失败(模型错误/超时/取消)抛错,由调用方决定容错语义。
|
|
60
99
|
*/
|
|
61
100
|
export async function runSubagent(opts) {
|
|
101
|
+
if (opts.signal?.aborted)
|
|
102
|
+
throw cancelledError(opts.signal);
|
|
62
103
|
sharedModelRuntime ??= await ModelRuntime.create();
|
|
63
104
|
let model;
|
|
64
105
|
if (opts.model) {
|
|
@@ -98,22 +139,17 @@ export async function runSubagent(opts) {
|
|
|
98
139
|
...(model ? { model } : {}),
|
|
99
140
|
...(opts.noTools ? { noTools: "all" } : {}),
|
|
100
141
|
});
|
|
101
|
-
const timeoutMs = opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS;
|
|
102
|
-
const timer = setTimeout(() => void session.abort(), timeoutMs);
|
|
103
|
-
const onParentAbort = () => void session.abort();
|
|
104
|
-
opts.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
105
142
|
try {
|
|
106
|
-
await session
|
|
143
|
+
await promptSubagentSession(session, opts.task, {
|
|
144
|
+
timeoutMs: opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS,
|
|
145
|
+
signal: opts.signal,
|
|
146
|
+
});
|
|
147
|
+
const messages = session.messages;
|
|
148
|
+
return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
|
|
107
149
|
}
|
|
108
150
|
finally {
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
opts.signal?.removeEventListener("abort", onParentAbort);
|
|
111
151
|
session.dispose();
|
|
112
152
|
}
|
|
113
|
-
if (opts.signal?.aborted)
|
|
114
|
-
throw new Error("已取消");
|
|
115
|
-
const messages = session.messages;
|
|
116
|
-
return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
|
|
117
153
|
}
|
|
118
154
|
/**
|
|
119
155
|
* 固定并发的工作池:limit 个工人依次领任务跑完一个补位一个。
|
package/dist/tools.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { CliConfig } from "./config.js";
|
|
5
5
|
export declare function truncate(text: string): string;
|
|
6
|
+
/** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
|
|
7
|
+
export declare function readBodyCapped(resp: Response, maxBytes: number): Promise<Uint8Array>;
|
|
6
8
|
/** 联网工具通用渲染:调用行不占位,收起时只显一行摘要,ctrl+o 展开全文,出错显一行 ✗ */
|
|
7
9
|
export declare function compactResultRender(input: {
|
|
8
10
|
result: {
|
|
@@ -43,6 +45,8 @@ export declare function downloadGeneratedImage(url: string, signal: AbortSignal,
|
|
|
43
45
|
attempts?: number;
|
|
44
46
|
retryDelayMs?: number;
|
|
45
47
|
fetchImpl?: typeof fetch;
|
|
48
|
+
maxBytes?: number;
|
|
49
|
+
attemptTimeoutMs?: number;
|
|
46
50
|
}): Promise<Uint8Array>;
|
|
47
51
|
/** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
|
|
48
52
|
export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey">): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
|
|
@@ -54,6 +58,10 @@ export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey
|
|
|
54
58
|
path: string;
|
|
55
59
|
size: string | null;
|
|
56
60
|
bytes: number;
|
|
61
|
+
displayImage: {
|
|
62
|
+
data: string;
|
|
63
|
+
mimeType: string;
|
|
64
|
+
};
|
|
57
65
|
}, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
|
|
58
66
|
export interface FetchToolConfig {
|
|
59
67
|
baseUrl: string;
|