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/api.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type ApiThinkingCapabilities, type CliConfig } from "./config.js";
|
|
2
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
3
|
+
export declare function signalWithTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal;
|
|
4
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
5
|
+
export declare function readResponseTextCapped(resp: Response, maxBytes?: number): Promise<string>;
|
|
6
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
7
|
+
export declare function readJsonResponseCapped<T = unknown>(resp: Response, maxBytes?: number): Promise<T>;
|
|
2
8
|
export interface MeResponse {
|
|
3
9
|
email: string | null;
|
|
4
10
|
signup_credit_usd?: number;
|
|
@@ -66,6 +72,8 @@ export interface ModelsResponse {
|
|
|
66
72
|
announcement?: ApiAnnouncement | null;
|
|
67
73
|
/** Opaque, device-bound canary echoed only by the official signing proxy. */
|
|
68
74
|
clientAttestation?: string;
|
|
75
|
+
/** Remaining lifetime of the canary, so the proxy can refresh it before it expires. */
|
|
76
|
+
clientAttestationExpiresInSeconds?: number;
|
|
69
77
|
}
|
|
70
78
|
/** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
|
|
71
79
|
export declare class AuthError extends Error {
|
package/dist/api.js
CHANGED
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
import { apiEndpointToCustom, loadEndpointsCache, saveEndpointsCache, setCustomEndpoints, VERSION, } from "./config.js";
|
|
2
2
|
import { authorizedFetch } from "./device-auth.js";
|
|
3
|
+
const MAX_API_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MAX_API_ERROR_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_IMAGE_RESULT_BYTES = 64 * 1024;
|
|
6
|
+
function jsonRecord(value) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
/** Preserve caller cancellation while always enforcing the operation deadline. */
|
|
12
|
+
export function signalWithTimeout(signal, timeoutMs) {
|
|
13
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
14
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
15
|
+
}
|
|
16
|
+
/** Read a complete response without allowing a custom or faulty server to exhaust CLI memory. */
|
|
17
|
+
export async function readResponseTextCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
18
|
+
const limit = Math.max(1, Math.floor(maxBytes));
|
|
19
|
+
const declaredLength = Number(resp.headers.get("content-length"));
|
|
20
|
+
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
21
|
+
void resp.body?.cancel("API response body limit reached").catch(() => { });
|
|
22
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
23
|
+
}
|
|
24
|
+
if (!resp.body)
|
|
25
|
+
throw new Error("API response body is missing");
|
|
26
|
+
const reader = resp.body.getReader();
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
let text = "";
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done) {
|
|
34
|
+
text += decoder.decode();
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (bytes + value.byteLength > limit) {
|
|
38
|
+
void reader.cancel("API response body limit reached").catch(() => { });
|
|
39
|
+
throw new Error(`API response body exceeds ${limit} bytes`);
|
|
40
|
+
}
|
|
41
|
+
bytes += value.byteLength;
|
|
42
|
+
text += decoder.decode(value, { stream: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
try {
|
|
47
|
+
reader.releaseLock();
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
/** Parse JSON only after the complete response has passed the shared byte cap. */
|
|
54
|
+
export async function readJsonResponseCapped(resp, maxBytes = MAX_API_RESPONSE_BYTES) {
|
|
55
|
+
return JSON.parse(await readResponseTextCapped(resp, maxBytes));
|
|
56
|
+
}
|
|
3
57
|
/** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
|
|
4
58
|
export class AuthError extends Error {
|
|
5
59
|
}
|
|
6
60
|
/** 网关错误壳统一是 { error: { message } };解析不出来时回退到状态码提示。 */
|
|
7
61
|
async function errorMessageFromResponse(resp, fallback) {
|
|
8
|
-
const body = (await resp
|
|
9
|
-
|
|
62
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_API_ERROR_BYTES).catch(() => null));
|
|
63
|
+
const error = jsonRecord(body?.error);
|
|
64
|
+
return typeof error?.message === "string" ? error.message.slice(0, 2_000) : fallback;
|
|
10
65
|
}
|
|
11
66
|
export async function fetchModels(cfg) {
|
|
12
67
|
let resp;
|
|
@@ -23,13 +78,17 @@ export async function fetchModels(cfg) {
|
|
|
23
78
|
throw new AuthError("登录已失效,请重新运行 u1s1 login");
|
|
24
79
|
if (!resp.ok)
|
|
25
80
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
26
|
-
const body =
|
|
81
|
+
const body = await readJsonResponseCapped(resp);
|
|
82
|
+
if (!body || !Array.isArray(body.data))
|
|
83
|
+
throw new Error("服务端模型列表格式不正确");
|
|
27
84
|
const token = body.client_attestation?.token;
|
|
85
|
+
const expiresIn = body.client_attestation?.expires_in;
|
|
28
86
|
return {
|
|
29
87
|
models: body.data,
|
|
30
88
|
features: body.features ?? {},
|
|
31
89
|
announcement: body.announcement,
|
|
32
90
|
clientAttestation: typeof token === "string" && token.length <= 1024 ? token : undefined,
|
|
91
|
+
clientAttestationExpiresInSeconds: typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : undefined,
|
|
33
92
|
};
|
|
34
93
|
}
|
|
35
94
|
/**
|
|
@@ -49,8 +108,8 @@ export async function fetchUserEndpoints(cfg) {
|
|
|
49
108
|
}
|
|
50
109
|
if (!resp.ok)
|
|
51
110
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status}`));
|
|
52
|
-
const body = (await resp
|
|
53
|
-
return Array.isArray(body
|
|
111
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
112
|
+
return Array.isArray(body?.endpoints) ? body.endpoints : [];
|
|
54
113
|
}
|
|
55
114
|
/**
|
|
56
115
|
* 拉取云端配置的自定义端点并装载进 CUSTOM_ENDPOINTS;失败(离线/老网关)回退
|
|
@@ -79,17 +138,37 @@ export async function searchWeb(cfg, input) {
|
|
|
79
138
|
"content-type": "application/json",
|
|
80
139
|
},
|
|
81
140
|
body: JSON.stringify({ query: input.query, max_results: input.maxResults }),
|
|
82
|
-
signal: input.signal
|
|
141
|
+
signal: signalWithTimeout(input.signal, 60_000),
|
|
83
142
|
});
|
|
84
143
|
}
|
|
85
|
-
catch {
|
|
86
|
-
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (input.signal?.aborted)
|
|
146
|
+
throw error;
|
|
147
|
+
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`, { cause: error });
|
|
87
148
|
}
|
|
88
149
|
if (resp.status === 401)
|
|
89
150
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
90
151
|
if (!resp.ok)
|
|
91
152
|
throw new Error(await errorMessageFromResponse(resp, `搜索服务返回 ${resp.status},稍后再试`));
|
|
92
|
-
|
|
153
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
154
|
+
if (!body || !Array.isArray(body.results))
|
|
155
|
+
throw new Error("搜索服务返回格式不正确");
|
|
156
|
+
const results = [];
|
|
157
|
+
for (const value of body.results.slice(0, 10)) {
|
|
158
|
+
const result = jsonRecord(value);
|
|
159
|
+
if (!result
|
|
160
|
+
|| typeof result.title !== "string"
|
|
161
|
+
|| typeof result.url !== "string"
|
|
162
|
+
|| typeof result.snippet !== "string") {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
results.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
query: typeof body.query === "string" ? body.query : input.query,
|
|
169
|
+
answer: typeof body.answer === "string" ? body.answer : null,
|
|
170
|
+
results,
|
|
171
|
+
};
|
|
93
172
|
}
|
|
94
173
|
/** web_fetch 直连失败时的回退:网关用 Cloudflare Browser Rendering 渲染后转 markdown。 */
|
|
95
174
|
export async function renderPage(cfg, url, signal) {
|
|
@@ -104,31 +183,40 @@ export async function renderPage(cfg, url, signal) {
|
|
|
104
183
|
"content-type": "application/json",
|
|
105
184
|
},
|
|
106
185
|
body: JSON.stringify({ url }),
|
|
107
|
-
signal: signal
|
|
186
|
+
signal: signalWithTimeout(signal, 90_000),
|
|
108
187
|
});
|
|
109
188
|
}
|
|
110
|
-
catch {
|
|
111
|
-
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (signal?.aborted)
|
|
191
|
+
throw error;
|
|
192
|
+
throw new Error(`连不上 ${cfg.baseUrl}`, { cause: error });
|
|
112
193
|
}
|
|
113
194
|
if (resp.status === 401)
|
|
114
195
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
115
196
|
if (!resp.ok)
|
|
116
197
|
throw new Error(await errorMessageFromResponse(resp, `渲染服务返回 ${resp.status}`));
|
|
117
|
-
|
|
198
|
+
const body = jsonRecord(await readJsonResponseCapped(resp));
|
|
199
|
+
if (!body || typeof body.url !== "string" || typeof body.markdown !== "string") {
|
|
200
|
+
throw new Error("渲染服务返回格式不正确");
|
|
201
|
+
}
|
|
202
|
+
return { url: body.url, markdown: body.markdown };
|
|
118
203
|
}
|
|
119
204
|
const IMAGE_URL_HEADER = "x-u1s1-image-url";
|
|
120
205
|
const IMAGE_SIZE_HEADER = "x-u1s1-image-size";
|
|
121
206
|
function imageResultFromHeaders(resp) {
|
|
122
207
|
const encodedUrl = resp.headers.get(IMAGE_URL_HEADER);
|
|
123
|
-
if (!encodedUrl)
|
|
208
|
+
if (!encodedUrl || encodedUrl.length > 8_000)
|
|
124
209
|
return null;
|
|
125
210
|
try {
|
|
126
211
|
const url = decodeURIComponent(encodedUrl);
|
|
212
|
+
if (/[\u0000-\u001f\u007f]/.test(url))
|
|
213
|
+
return null;
|
|
127
214
|
const parsed = new URL(url);
|
|
128
215
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
129
216
|
return null;
|
|
130
217
|
const encodedSize = resp.headers.get(IMAGE_SIZE_HEADER);
|
|
131
|
-
|
|
218
|
+
const size = encodedSize ? decodeURIComponent(encodedSize) : null;
|
|
219
|
+
return { url, size: size && size.length <= 100 ? size : null };
|
|
132
220
|
}
|
|
133
221
|
catch {
|
|
134
222
|
return null;
|
|
@@ -147,15 +235,28 @@ function responseErrorDetail(error) {
|
|
|
147
235
|
export async function readGeneratedImageResponse(resp) {
|
|
148
236
|
const headerResult = imageResultFromHeaders(resp);
|
|
149
237
|
if (headerResult) {
|
|
150
|
-
|
|
238
|
+
void resp.body?.cancel("image result recovered from response headers").catch(() => undefined);
|
|
151
239
|
return headerResult;
|
|
152
240
|
}
|
|
153
241
|
try {
|
|
154
|
-
const body = (await resp
|
|
155
|
-
if (typeof body
|
|
242
|
+
const body = jsonRecord(await readJsonResponseCapped(resp, MAX_IMAGE_RESULT_BYTES));
|
|
243
|
+
if (typeof body?.url !== "string") {
|
|
244
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
245
|
+
}
|
|
246
|
+
let parsed;
|
|
247
|
+
try {
|
|
248
|
+
parsed = new URL(body.url);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
throw new Error("服务端没有返回图片下载地址");
|
|
252
|
+
}
|
|
253
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
156
254
|
throw new Error("服务端没有返回图片下载地址");
|
|
157
255
|
}
|
|
158
|
-
return {
|
|
256
|
+
return {
|
|
257
|
+
url: body.url,
|
|
258
|
+
size: typeof body.size === "string" && body.size.length <= 100 ? body.size : null,
|
|
259
|
+
};
|
|
159
260
|
}
|
|
160
261
|
catch (error) {
|
|
161
262
|
throw new Error(`图片可能已经生成,但客户端下载结果响应失败: ${responseErrorDetail(error)}。` +
|
|
@@ -175,7 +276,7 @@ export async function generateImage(cfg, req, signal) {
|
|
|
175
276
|
"content-type": "application/json",
|
|
176
277
|
},
|
|
177
278
|
body: JSON.stringify(req),
|
|
178
|
-
signal: signal
|
|
279
|
+
signal: signalWithTimeout(signal, 180_000),
|
|
179
280
|
});
|
|
180
281
|
}
|
|
181
282
|
catch (error) {
|
|
@@ -206,5 +307,8 @@ export async function fetchMe(cfg) {
|
|
|
206
307
|
throw new Error("登录已失效,请重新运行 u1s1 login");
|
|
207
308
|
if (!resp.ok)
|
|
208
309
|
throw new Error(await errorMessageFromResponse(resp, `服务端返回 ${resp.status},稍后再试`));
|
|
209
|
-
|
|
310
|
+
const body = await readJsonResponseCapped(resp);
|
|
311
|
+
if (!body || typeof body !== "object")
|
|
312
|
+
throw new Error("服务端账户响应格式不正确");
|
|
313
|
+
return body;
|
|
210
314
|
}
|
package/dist/bench.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { CUSTOM_ENDPOINTS, loadConfig, MODELS, PROVIDER_ID, } from "./config.js";
|
|
6
|
-
import { loadCustomEndpoints } from "./api.js";
|
|
6
|
+
import { loadCustomEndpoints, readJsonResponseCapped, readResponseTextCapped } from "./api.js";
|
|
7
7
|
import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
|
|
8
8
|
import { costStr, errorRate, formatDuration, generateReport, renderTable, scoreBar, scoreRate, sortByScore, summarizeByModel, tokPerSec, } from "./bench-report.js";
|
|
9
9
|
import { scoreResponse } from "./bench-scoring.js";
|
|
@@ -108,15 +108,32 @@ async function callModel(input) {
|
|
|
108
108
|
: await fetch(`${baseUrl}/chat/completions`, request);
|
|
109
109
|
const latencyMs = Math.round(performance.now() - start);
|
|
110
110
|
if (!res.ok) {
|
|
111
|
-
const errBody = await res
|
|
111
|
+
const errBody = await readResponseTextCapped(res, 64 * 1024).catch(() => "未知错误");
|
|
112
112
|
return { response: "", latencyMs, tokensIn: 0, tokensOut: 0, error: `HTTP ${res.status}: ${errBody.slice(0, 200)}` };
|
|
113
113
|
}
|
|
114
|
-
const
|
|
114
|
+
const value = await readJsonResponseCapped(res);
|
|
115
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
116
|
+
? value
|
|
117
|
+
: null;
|
|
118
|
+
const choices = Array.isArray(data?.choices) ? data.choices : [];
|
|
119
|
+
const firstChoice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
|
|
120
|
+
? choices[0]
|
|
121
|
+
: null;
|
|
122
|
+
const message = firstChoice?.message && typeof firstChoice.message === "object" && !Array.isArray(firstChoice.message)
|
|
123
|
+
? firstChoice.message
|
|
124
|
+
: null;
|
|
125
|
+
const usage = data?.usage && typeof data.usage === "object" && !Array.isArray(data.usage)
|
|
126
|
+
? data.usage
|
|
127
|
+
: null;
|
|
115
128
|
return {
|
|
116
|
-
response:
|
|
129
|
+
response: typeof message?.content === "string" ? message.content : "",
|
|
117
130
|
latencyMs,
|
|
118
|
-
tokensIn:
|
|
119
|
-
|
|
131
|
+
tokensIn: typeof usage?.prompt_tokens === "number" && Number.isFinite(usage.prompt_tokens)
|
|
132
|
+
? Math.max(0, usage.prompt_tokens)
|
|
133
|
+
: 0,
|
|
134
|
+
tokensOut: typeof usage?.completion_tokens === "number" && Number.isFinite(usage.completion_tokens)
|
|
135
|
+
? Math.max(0, usage.completion_tokens)
|
|
136
|
+
: 0,
|
|
120
137
|
};
|
|
121
138
|
}
|
|
122
139
|
catch (e) {
|
package/dist/deploy.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type CliConfig } from "./config.js";
|
|
2
|
+
interface SiteFile {
|
|
3
|
+
path: string;
|
|
4
|
+
abs: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function collectFiles(root: string): SiteFile[];
|
|
2
8
|
type SiteVisibility = "public" | "private";
|
|
3
9
|
interface DeployArgs {
|
|
4
10
|
name?: string;
|
package/dist/deploy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { VERSION, u1s1Dir } from "./config.js";
|
|
5
5
|
import { authorizedFetch } from "./device-auth.js";
|
|
6
|
+
import { readJsonResponseCapped } from "./api.js";
|
|
6
7
|
/**
|
|
7
8
|
* u1s1 deploy publishes a static site to <project>.<account-code>.u1s1.app.
|
|
8
9
|
* Detect the site root, ask for a project slug, and remember it in ~/.u1s1/deploys.json.
|
|
@@ -12,6 +13,8 @@ const deploysFile = join(u1s1Dir, "deploys.json");
|
|
|
12
13
|
/** 构建产物目录优先:Vite/Next 等项目根的 index.html 是源码,不是能直接上线的产物。 */
|
|
13
14
|
const BUILD_DIRS = ["dist", "build", "out", "_site", "public"];
|
|
14
15
|
const SKIP_DIRS = new Set(["node_modules", "__pycache__"]);
|
|
16
|
+
const MAX_DEPLOY_API_RESPONSE_BYTES = 256 * 1024;
|
|
17
|
+
const MAX_DEPLOY_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
15
18
|
function readDeploys() {
|
|
16
19
|
try {
|
|
17
20
|
return JSON.parse(readFileSync(deploysFile, "utf8"));
|
|
@@ -56,14 +59,18 @@ function resolveSiteDir(explicit) {
|
|
|
56
59
|
" 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
|
|
57
60
|
" 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
|
|
58
61
|
}
|
|
59
|
-
function collectFiles(root) {
|
|
62
|
+
export function collectFiles(root) {
|
|
60
63
|
const files = [];
|
|
61
64
|
const walk = (dir) => {
|
|
62
65
|
for (const name of readdirSync(dir)) {
|
|
63
66
|
if (name.startsWith(".") || SKIP_DIRS.has(name))
|
|
64
67
|
continue;
|
|
65
68
|
const abs = join(dir, name);
|
|
66
|
-
const st =
|
|
69
|
+
const st = lstatSync(abs);
|
|
70
|
+
// Never upload or recurse through links: a project-controlled link could
|
|
71
|
+
// escape the selected root, expose credentials, or create a directory cycle.
|
|
72
|
+
if (st.isSymbolicLink())
|
|
73
|
+
continue;
|
|
67
74
|
if (st.isDirectory())
|
|
68
75
|
walk(abs);
|
|
69
76
|
else if (st.isFile()) {
|
|
@@ -136,12 +143,25 @@ async function api(cfg, request) {
|
|
|
136
143
|
catch {
|
|
137
144
|
throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
|
|
138
145
|
}
|
|
139
|
-
const
|
|
146
|
+
const value = await readJsonResponseCapped(resp, MAX_DEPLOY_API_RESPONSE_BYTES).catch(() => null);
|
|
147
|
+
const data = value && typeof value === "object" && !Array.isArray(value)
|
|
148
|
+
? value
|
|
149
|
+
: null;
|
|
140
150
|
if (!resp.ok) {
|
|
141
|
-
const
|
|
142
|
-
|
|
151
|
+
const detail = data?.error && typeof data.error === "object"
|
|
152
|
+
? data.error
|
|
153
|
+
: undefined;
|
|
154
|
+
const message = typeof detail?.message === "string"
|
|
155
|
+
? detail.message.slice(0, 2_000)
|
|
156
|
+
: `服务端返回 ${resp.status},稍后再试`;
|
|
157
|
+
const e = new Error(message);
|
|
158
|
+
if (typeof detail?.code === "string" && detail.code.length <= 100) {
|
|
159
|
+
e.code = detail.code;
|
|
160
|
+
}
|
|
143
161
|
throw e;
|
|
144
162
|
}
|
|
163
|
+
if (!data)
|
|
164
|
+
throw new Error("服务端部署响应格式不正确");
|
|
145
165
|
return data;
|
|
146
166
|
}
|
|
147
167
|
/** 逐个上传,失败重试一次;并发数保守取 6。 */
|
|
@@ -157,12 +177,24 @@ async function uploadAll(cfg, start, files) {
|
|
|
157
177
|
signal: AbortSignal.timeout(120_000),
|
|
158
178
|
});
|
|
159
179
|
let resp = await put().catch(() => null);
|
|
160
|
-
if (!resp
|
|
180
|
+
if (resp && !resp.ok) {
|
|
181
|
+
void resp.body?.cancel("retrying rejected deploy upload").catch(() => { });
|
|
161
182
|
resp = await put().catch(() => null);
|
|
183
|
+
}
|
|
184
|
+
else if (!resp) {
|
|
185
|
+
resp = await put().catch(() => null);
|
|
186
|
+
}
|
|
162
187
|
if (!resp?.ok) {
|
|
163
|
-
const
|
|
164
|
-
|
|
188
|
+
const value = resp
|
|
189
|
+
? await readJsonResponseCapped(resp, MAX_DEPLOY_ERROR_RESPONSE_BYTES).catch(() => null)
|
|
190
|
+
: null;
|
|
191
|
+
const body = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
192
|
+
const message = body?.error && typeof body.error === "object" && typeof body.error.message === "string"
|
|
193
|
+
? body.error.message.slice(0, 2_000)
|
|
194
|
+
: "网络错误";
|
|
195
|
+
throw new Error(`上传 ${f.path} 失败:${message}`);
|
|
165
196
|
}
|
|
197
|
+
void resp.body?.cancel("deploy upload completed").catch(() => { });
|
|
166
198
|
done++;
|
|
167
199
|
process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
|
|
168
200
|
};
|
package/dist/device-auth.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { webcrypto } from "node:crypto";
|
|
2
|
+
import { type IncomingMessage } from "node:http";
|
|
2
3
|
import { type CliConfig } from "./config.js";
|
|
3
4
|
export declare function hasDeviceCredential(cfg: CliConfig): boolean;
|
|
4
5
|
export declare function generateDeviceKeyPair(): Promise<{
|
|
@@ -8,17 +9,46 @@ export declare function generateDeviceKeyPair(): Promise<{
|
|
|
8
9
|
export declare function dpopHeaders(cfg: CliConfig, method: string, url: string): Promise<Record<string, string>>;
|
|
9
10
|
/** Fetch a gateway route with a fresh proof; generic keys remain a read-only compatibility fallback. */
|
|
10
11
|
export declare function authorizedFetch(cfg: CliConfig, input: string | URL, init?: RequestInit): Promise<Response>;
|
|
12
|
+
/** Buffer a loopback request only up to the same JSON limit enforced by Gateway. */
|
|
13
|
+
export declare function readSigningProxyRequestBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer | undefined>;
|
|
14
|
+
/**
|
|
15
|
+
* How the proxy obtains and refreshes the device attestation canary. The token
|
|
16
|
+
* is device-bound and expires (7 days server-side); the proxy keeps it fresh so
|
|
17
|
+
* a healthy Gateway always sees a valid attestation instead of falling back to
|
|
18
|
+
* the weaker "legacy official" verdict when a transient /v1/models fetch missed.
|
|
19
|
+
*/
|
|
20
|
+
export interface ClientAttestationSource {
|
|
21
|
+
token?: string;
|
|
22
|
+
expiresInSeconds?: number;
|
|
23
|
+
/** Fetch a fresh token (e.g. via GET /v1/models). Single-flighted by the proxy. */
|
|
24
|
+
refresh?: () => Promise<{
|
|
25
|
+
token?: string;
|
|
26
|
+
expiresInSeconds?: number;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
interface AttestationHolder {
|
|
30
|
+
token?: string;
|
|
31
|
+
expiresAtMs?: number;
|
|
32
|
+
refresh?: () => Promise<{
|
|
33
|
+
token?: string;
|
|
34
|
+
expiresInSeconds?: number;
|
|
35
|
+
}>;
|
|
36
|
+
refreshing?: Promise<void>;
|
|
37
|
+
lastFailureMs?: number;
|
|
38
|
+
}
|
|
11
39
|
interface SigningProxy {
|
|
12
40
|
baseUrl: string;
|
|
13
41
|
localKey: string;
|
|
14
42
|
token: string;
|
|
15
43
|
client: ClientSurface;
|
|
16
|
-
|
|
44
|
+
attestation: AttestationHolder;
|
|
17
45
|
}
|
|
46
|
+
/** Attach a fresh (self-healing) attestation header to an outbound proxy request. */
|
|
47
|
+
export declare function attachAttestationHeader(holder: AttestationHolder, headers: Headers): Promise<void>;
|
|
18
48
|
export type ClientSurface = "terminal" | "web" | "desktop" | "cloud";
|
|
19
49
|
/**
|
|
20
50
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
21
51
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
22
52
|
*/
|
|
23
|
-
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface,
|
|
53
|
+
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, attestationSource?: ClientAttestationSource): Promise<SigningProxy>;
|
|
24
54
|
export {};
|