u1s1-cli 1.17.0 → 1.19.0
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/README.md +25 -50
- package/dist/agents/claude.d.ts +4 -1
- package/dist/agents/claude.js +6 -3
- package/dist/agents/codex.d.ts +4 -1
- package/dist/agents/codex.js +6 -3
- package/dist/agents/custom-models.d.ts +61 -0
- package/dist/agents/custom-models.js +175 -0
- package/dist/agents/launch.d.ts +3 -0
- package/dist/agents/launch.js +17 -5
- package/dist/agents/models-list.d.ts +65 -0
- package/dist/agents/models-list.js +297 -0
- package/dist/api.d.ts +2 -0
- package/dist/chat-protocol/anthropic.d.ts +29 -0
- package/dist/chat-protocol/anthropic.js +484 -0
- package/dist/chat-protocol/json.d.ts +20 -0
- package/dist/chat-protocol/json.js +22 -0
- package/dist/chat-protocol/responses.d.ts +97 -0
- package/dist/chat-protocol/responses.js +1076 -0
- package/dist/chat-protocol/sse.d.ts +19 -0
- package/dist/chat-protocol/sse.js +67 -0
- package/dist/chat-protocol/thinking.d.ts +22 -0
- package/dist/chat-protocol/thinking.js +58 -0
- package/dist/config-command.js +5 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.js +51 -0
- package/dist/custom-endpoint-proxy.d.ts +92 -0
- package/dist/custom-endpoint-proxy.js +461 -0
- package/dist/device-auth.d.ts +13 -2
- package/dist/device-auth.js +36 -2
- package/dist/index.js +9 -1
- package/dist/launcher.js +8 -0
- package/package.json +4 -4
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { anthropicErrorBody, anthropicRequestToChat, chatCompletionToAnthropic, chatSseToAnthropic, } from "./chat-protocol/anthropic.js";
|
|
3
|
+
import { asRecord } from "./chat-protocol/json.js";
|
|
4
|
+
import { openAIResponseToResponses, responsesContext, responsesFinalError, responsesRequestToChat, } from "./chat-protocol/responses.js";
|
|
5
|
+
import { estimateTokensFromText } from "./chat-protocol/thinking.js";
|
|
6
|
+
import { lookupCustomModel } from "./agents/custom-models.js";
|
|
7
|
+
import { describeFetchFailure, isConnectPhaseFailure } from "./gateway-route.js";
|
|
8
|
+
import { tr } from "./i18n.js";
|
|
9
|
+
/** 本地路由只看这三条;其余路径(/v1/models、/v1/agents/…)照旧走网关。 */
|
|
10
|
+
export function customProtocolFor(method, pathname) {
|
|
11
|
+
if ((method ?? "GET").toUpperCase() !== "POST")
|
|
12
|
+
return null;
|
|
13
|
+
if (pathname === "/v1/messages")
|
|
14
|
+
return "messages";
|
|
15
|
+
if (pathname === "/v1/messages/count_tokens")
|
|
16
|
+
return "count_tokens";
|
|
17
|
+
if (pathname === "/v1/responses")
|
|
18
|
+
return "responses";
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 这个请求要不要本地处理。只有 model 是 `ep…/…` 写法时才是;端点或模型已不在时也本地报错
|
|
23
|
+
* (网关不认识这种 id,发过去只会多一次网关请求)。body 里连 `"ep` 都没有时不解析 JSON。
|
|
24
|
+
*/
|
|
25
|
+
export function matchCustomRoute(method, pathname, rawBody, endpoints) {
|
|
26
|
+
const protocol = customProtocolFor(method, pathname);
|
|
27
|
+
if (!protocol || !rawBody || !rawBody.includes('"ep'))
|
|
28
|
+
return null;
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
body = asRecord(JSON.parse(rawBody.toString("utf8")));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!body)
|
|
37
|
+
return null;
|
|
38
|
+
const lookup = lookupCustomModel(endpoints, body.model);
|
|
39
|
+
return lookup.kind === "gateway" ? null : { protocol, body, lookup };
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------- reasoning
|
|
42
|
+
const LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
43
|
+
/** 支持档位里离 want 最近的一档;等距取高的一档(与网关 anthropic-thinking.ts 同一规则)。 */
|
|
44
|
+
function nearestLevel(want, supported) {
|
|
45
|
+
const target = LEVEL_ORDER.indexOf(want);
|
|
46
|
+
let best;
|
|
47
|
+
for (const level of supported) {
|
|
48
|
+
if (best === undefined) {
|
|
49
|
+
best = level;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const gap = Math.abs(LEVEL_ORDER.indexOf(level) - target);
|
|
53
|
+
const bestGap = Math.abs(LEVEL_ORDER.indexOf(best) - target);
|
|
54
|
+
if (gap < bestGap || (gap === bestGap && LEVEL_ORDER.indexOf(level) > LEVEL_ORDER.indexOf(best)))
|
|
55
|
+
best = level;
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 客户端请求的思考设置 → Chat 请求里的推理参数(自定义端点只发 `reasoning_effort`;不发 DeepSeek 的顶层
|
|
61
|
+
* `thinking`、不发 chat_template_kwargs:有的端点见到 thinking 直接 400)。
|
|
62
|
+
* - 非推理模型、客户端没表态(level = null):什么都不发,用端点默认;
|
|
63
|
+
* - 关思考:发 "none"(省略等于让后端按默认开着想);
|
|
64
|
+
* - 其余档位:原样发(max 收到 xhigh,OpenAI 兼容端点不认 max)。
|
|
65
|
+
* 模型带思考契约(ModelDef.thinking:levels / canDisable / levelMap,后台「推理参数格式」字段)时,
|
|
66
|
+
* 档位取它支持的最近一档、按 levelMap 改写;关不掉的模型收到「关」退到最低一档。requestFormat 不影响
|
|
67
|
+
* 字段名:自定义端点一律 reasoning_effort。
|
|
68
|
+
*/
|
|
69
|
+
export function chatReasoningFields(model, level) {
|
|
70
|
+
if (!model.reasoning || level === null)
|
|
71
|
+
return {};
|
|
72
|
+
const caps = model.thinking;
|
|
73
|
+
if (!caps || !caps.levels.length)
|
|
74
|
+
return { reasoning_effort: level === "off" ? "none" : level === "max" ? "xhigh" : level };
|
|
75
|
+
const onLevels = caps.levels.filter((l) => l !== "off");
|
|
76
|
+
if (level === "off" && caps.canDisable)
|
|
77
|
+
return { reasoning_effort: caps.levelMap.off ?? "none" };
|
|
78
|
+
const chosen = level === "off" ? onLevels[0] : nearestLevel(level, onLevels);
|
|
79
|
+
if (!chosen)
|
|
80
|
+
return {};
|
|
81
|
+
return { reasoning_effort: caps.levelMap[chosen] ?? (chosen === "max" ? "xhigh" : chosen) };
|
|
82
|
+
}
|
|
83
|
+
function reasoningFieldsFor(model) {
|
|
84
|
+
return (_modelId, level) => chatReasoningFields(model, level);
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------- outbound request
|
|
87
|
+
/** 客户端会话 id → x-session-id(端点按它把同一会话固定到同一后端,提高缓存命中)。只认这几个头。 */
|
|
88
|
+
const SESSION_HEADERS = ["x-claude-code-session-id", "session_id", "session-id", "x-session-id"];
|
|
89
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
90
|
+
export function clientSessionId(headers) {
|
|
91
|
+
for (const name of SESSION_HEADERS) {
|
|
92
|
+
const value = headers[name];
|
|
93
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
94
|
+
if (typeof first === "string" && SESSION_ID_RE.test(first))
|
|
95
|
+
return first;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** 发往端点的请求头:从零构造,见文件头的安全说明。 */
|
|
100
|
+
export function endpointRequestHeaders(endpoint, stream, sessionId) {
|
|
101
|
+
const headers = new Headers({
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
accept: stream ? "text/event-stream" : "application/json",
|
|
104
|
+
});
|
|
105
|
+
if (endpoint.apiKey)
|
|
106
|
+
headers.set("authorization", `Bearer ${endpoint.apiKey}`);
|
|
107
|
+
if (sessionId)
|
|
108
|
+
headers.set("x-session-id", sessionId);
|
|
109
|
+
return headers;
|
|
110
|
+
}
|
|
111
|
+
export function chatCompletionsUrl(baseUrl) {
|
|
112
|
+
return `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
113
|
+
}
|
|
114
|
+
/** 转换后的 Chat 体 → 真正发给端点的体:模型 id 换成端点自己的,流式要 usage,输出上限不超过模型登记的值。 */
|
|
115
|
+
export function endpointChatBody(chat, model) {
|
|
116
|
+
const out = { ...chat, model: model.id };
|
|
117
|
+
// 网关管道里给上游会话亲和用的字段;通用端点不一定认,不发
|
|
118
|
+
delete out.prompt_cache_key;
|
|
119
|
+
if (out.stream === true)
|
|
120
|
+
out.stream_options = { include_usage: true };
|
|
121
|
+
if (typeof out.max_tokens === "number" && model.maxTokens > 0 && out.max_tokens > model.maxTokens) {
|
|
122
|
+
out.max_tokens = model.maxTokens;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
const CONTEXT_ERROR_RE = /context length|context window|maximum context|too many tokens|prompt is too long|reduce the length/i;
|
|
127
|
+
function endpointLabel(endpoint) {
|
|
128
|
+
return tr(`自定义端点「${endpoint.name}」`, `Custom endpoint "${endpoint.name}"`);
|
|
129
|
+
}
|
|
130
|
+
function errorMessageOf(text) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = asRecord(JSON.parse(text));
|
|
133
|
+
const error = parsed?.error;
|
|
134
|
+
if (typeof error === "string" && error)
|
|
135
|
+
return error.slice(0, 1_000);
|
|
136
|
+
const record = asRecord(error);
|
|
137
|
+
if (typeof record?.message === "string" && record.message)
|
|
138
|
+
return record.message.slice(0, 1_000);
|
|
139
|
+
if (typeof parsed?.message === "string" && parsed.message)
|
|
140
|
+
return parsed.message.slice(0, 1_000);
|
|
141
|
+
if (typeof parsed?.detail === "string" && parsed.detail)
|
|
142
|
+
return parsed.detail.slice(0, 1_000);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// not JSON: raw text below
|
|
146
|
+
}
|
|
147
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
148
|
+
}
|
|
149
|
+
export function httpFailure(endpoint, status, text) {
|
|
150
|
+
const detail = errorMessageOf(text);
|
|
151
|
+
const what = endpointLabel(endpoint);
|
|
152
|
+
if (status === 400 && CONTEXT_ERROR_RE.test(detail)) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "context",
|
|
155
|
+
status: 400,
|
|
156
|
+
// Claude Code 认「Prompt is too long」开头的错误做上下文处理
|
|
157
|
+
message: `Prompt is too long: ${tr(`${what}报告超出上下文窗口`, `${what} reports the context window is exceeded`)}${detail ? ` (${detail})` : ""}`,
|
|
158
|
+
retry: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const retry = status === 408 || status === 409 || status === 429 || status >= 500;
|
|
162
|
+
return {
|
|
163
|
+
kind: "http",
|
|
164
|
+
status: status >= 400 && status <= 599 ? status : 502,
|
|
165
|
+
message: tr(`${what}返回 HTTP ${status}${detail ? `:${detail}` : ""}(这是你自己配置的端点,不是 u1s1 服务)`, `${what} returned HTTP ${status}${detail ? `: ${detail}` : ""} (this is your own endpoint, not the u1s1 service)`),
|
|
166
|
+
retry,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function transportFailure(endpoint, error) {
|
|
170
|
+
const failure = describeFetchFailure(error);
|
|
171
|
+
const what = endpointLabel(endpoint);
|
|
172
|
+
const timeout = /TIMEOUT|ETIMEDOUT/i.test(failure.code) || (error instanceof Error && error.name === "TimeoutError");
|
|
173
|
+
if (timeout && !isConnectPhaseFailure(failure)) {
|
|
174
|
+
return {
|
|
175
|
+
kind: "timeout",
|
|
176
|
+
status: 504,
|
|
177
|
+
message: tr(`${what}响应超时(${failure.detail})`, `${what} timed out (${failure.detail})`),
|
|
178
|
+
retry: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
kind: "connect",
|
|
183
|
+
status: 502,
|
|
184
|
+
message: tr(`连不上${what}(${failure.detail}),检查端点地址和网络。`, `Can't reach ${what} (${failure.detail}). Check its URL and your network.`),
|
|
185
|
+
retry: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function malformedFailure(endpoint, what) {
|
|
189
|
+
return {
|
|
190
|
+
kind: "malformed",
|
|
191
|
+
status: 502,
|
|
192
|
+
message: tr(`${endpointLabel(endpoint)}的返回不是 OpenAI Chat Completions 格式(${what}),检查端点地址是否指向 …/v1。`, `${endpointLabel(endpoint)} did not answer in OpenAI Chat Completions format (${what}). Check that its URL points at …/v1.`),
|
|
193
|
+
retry: false,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function missingFailure(lookup) {
|
|
197
|
+
return {
|
|
198
|
+
kind: "http",
|
|
199
|
+
status: 404,
|
|
200
|
+
message: tr(`找不到自定义模型 ${lookup.endpointId}/${lookup.modelId}:端点或模型可能已在控制台删除或改名。`, `Custom model ${lookup.endpointId}/${lookup.modelId} not found: the endpoint or model may have been removed or renamed in the console.`),
|
|
201
|
+
retry: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Anthropic 错误形状(Claude Code):`{type: "error", error: {type, message}}` + x-should-retry。 */
|
|
205
|
+
export function anthropicFailureResponse(failure) {
|
|
206
|
+
return new Response(JSON.stringify(anthropicErrorBody(failure.status, failure.message)), {
|
|
207
|
+
status: failure.status,
|
|
208
|
+
headers: { "content-type": "application/json", "x-should-retry": String(failure.retry) },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Responses 错误形状(Codex)。不重试的错误用网关同款的最终形状(HTTP 400 invalid_prompt,Codex 只发一次、
|
|
213
|
+
* 显示 message);超窗给 context_length_exceeded;能重试的(超时、429、5xx)按网关「上游失败」的口径:
|
|
214
|
+
* 流式请求回流内 response.failed(Codex 重试 6 次而不是 30 次),非流式回原状态码。
|
|
215
|
+
*/
|
|
216
|
+
export async function responsesFailureResponse(failure, ctx) {
|
|
217
|
+
if (failure.kind === "context") {
|
|
218
|
+
return openAIResponseToResponses(errorResponse(400, failure.message, "context_length_exceeded"), ctx);
|
|
219
|
+
}
|
|
220
|
+
if (!failure.retry) {
|
|
221
|
+
return responsesFinalError({ code: "invalid_prompt", type: "invalid_request_error" }, failure.status, failure.message);
|
|
222
|
+
}
|
|
223
|
+
const code = failure.status === 429 ? "rate_limit_exceeded" : "upstream_error";
|
|
224
|
+
return openAIResponseToResponses(errorResponse(failure.status, failure.message, code), ctx);
|
|
225
|
+
}
|
|
226
|
+
function errorResponse(status, message, code) {
|
|
227
|
+
return new Response(JSON.stringify({ error: { message, code } }), {
|
|
228
|
+
status,
|
|
229
|
+
headers: { "content-type": "application/json" },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async function callEndpoint(found, chat, sessionId, signal, fetchImpl) {
|
|
233
|
+
const body = endpointChatBody(chat, found.model);
|
|
234
|
+
let response;
|
|
235
|
+
try {
|
|
236
|
+
response = await fetchImpl(chatCompletionsUrl(found.endpoint.baseUrl), {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: endpointRequestHeaders(found.endpoint, body.stream === true, sessionId),
|
|
239
|
+
body: JSON.stringify(body),
|
|
240
|
+
signal,
|
|
241
|
+
redirect: "error",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (signal.aborted)
|
|
246
|
+
throw error;
|
|
247
|
+
return { ok: false, failure: transportFailure(found.endpoint, error) };
|
|
248
|
+
}
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
const text = await readTextCapped(response).catch(() => "");
|
|
251
|
+
return { ok: false, failure: httpFailure(found.endpoint, response.status, text) };
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, response };
|
|
254
|
+
}
|
|
255
|
+
const MAX_JSON_BYTES = 16 * 1024 * 1024;
|
|
256
|
+
async function readTextCapped(response, max = MAX_JSON_BYTES) {
|
|
257
|
+
if (!response.body)
|
|
258
|
+
return "";
|
|
259
|
+
const reader = response.body.getReader();
|
|
260
|
+
const chunks = [];
|
|
261
|
+
let total = 0;
|
|
262
|
+
for (;;) {
|
|
263
|
+
const { done, value } = await reader.read();
|
|
264
|
+
if (done)
|
|
265
|
+
break;
|
|
266
|
+
total += value.byteLength;
|
|
267
|
+
if (total > max) {
|
|
268
|
+
await reader.cancel().catch(() => undefined);
|
|
269
|
+
throw new Error("response too large");
|
|
270
|
+
}
|
|
271
|
+
chunks.push(value);
|
|
272
|
+
}
|
|
273
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
274
|
+
}
|
|
275
|
+
/** 非流式的成功响应:读成 JSON;不是 JSON、或 200 里装着 error 都算失败。 */
|
|
276
|
+
async function readCompletion(found, response) {
|
|
277
|
+
let text;
|
|
278
|
+
try {
|
|
279
|
+
text = await readTextCapped(response);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return { ok: false, failure: transportFailure(found.endpoint, error) };
|
|
283
|
+
}
|
|
284
|
+
let payload = null;
|
|
285
|
+
try {
|
|
286
|
+
payload = asRecord(JSON.parse(text));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
payload = null;
|
|
290
|
+
}
|
|
291
|
+
if (!payload)
|
|
292
|
+
return { ok: false, failure: malformedFailure(found.endpoint, tr("不是 JSON", "not JSON")) };
|
|
293
|
+
if (payload.error !== undefined && payload.error !== null) {
|
|
294
|
+
return { ok: false, failure: httpFailure(found.endpoint, 502, text) };
|
|
295
|
+
}
|
|
296
|
+
if (!Array.isArray(payload.choices))
|
|
297
|
+
return { ok: false, failure: malformedFailure(found.endpoint, tr("没有 choices", "no choices")) };
|
|
298
|
+
return { ok: true, payload };
|
|
299
|
+
}
|
|
300
|
+
function isEventStream(response) {
|
|
301
|
+
return (response.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
302
|
+
}
|
|
303
|
+
const encoder = new TextEncoder();
|
|
304
|
+
/**
|
|
305
|
+
* 端点的 SSE 原文,读到一半出错(断线、超时)时补一帧 Chat 形状的 error,让下游转换器按「流中途失败」
|
|
306
|
+
* 收尾(Claude Code 收到 error 事件,Codex 收到 response.failed),而不是静默截断。
|
|
307
|
+
*/
|
|
308
|
+
export function guardedEventStream(body, endpoint) {
|
|
309
|
+
const reader = body.getReader();
|
|
310
|
+
return new ReadableStream({
|
|
311
|
+
async pull(controller) {
|
|
312
|
+
try {
|
|
313
|
+
const { done, value } = await reader.read();
|
|
314
|
+
if (done)
|
|
315
|
+
controller.close();
|
|
316
|
+
else
|
|
317
|
+
controller.enqueue(value);
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
const failure = transportFailure(endpoint, error);
|
|
321
|
+
const frame = { error: { message: tr(`${endpointLabel(endpoint)}的流中途断开:${failure.message}`, `${endpointLabel(endpoint)} stream broke off: ${failure.message}`), code: failure.status } };
|
|
322
|
+
controller.enqueue(encoder.encode(`\n\ndata: ${JSON.stringify(frame)}\n\n`));
|
|
323
|
+
controller.close();
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
cancel(reason) {
|
|
327
|
+
return reader.cancel(reason);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------- protocols
|
|
332
|
+
function collectText(value) {
|
|
333
|
+
if (typeof value === "string")
|
|
334
|
+
return value;
|
|
335
|
+
if (Array.isArray(value))
|
|
336
|
+
return value.map(collectText).join("");
|
|
337
|
+
const record = asRecord(value);
|
|
338
|
+
if (!record)
|
|
339
|
+
return "";
|
|
340
|
+
return Object.entries(record)
|
|
341
|
+
.filter(([key]) => key !== "role" && key !== "type" && key !== "id" && key !== "tool_call_id")
|
|
342
|
+
.map(([, field]) => collectText(field))
|
|
343
|
+
.join("");
|
|
344
|
+
}
|
|
345
|
+
async function serveMessages(match, found, deps) {
|
|
346
|
+
const clientModel = found.id;
|
|
347
|
+
const chat = anthropicRequestToChat(match.body, reasoningFieldsFor(found.model));
|
|
348
|
+
if (match.protocol === "count_tokens") {
|
|
349
|
+
const text = collectText(chat.messages) + collectText(chat.tools ?? "");
|
|
350
|
+
return Response.json({ input_tokens: estimateTokensFromText(text) });
|
|
351
|
+
}
|
|
352
|
+
const call = await callEndpoint(found, chat, deps.sessionId, deps.signal, deps.fetchImpl);
|
|
353
|
+
if (!call.ok)
|
|
354
|
+
return anthropicFailureResponse(call.failure);
|
|
355
|
+
if (chat.stream === true && isEventStream(call.response) && call.response.body) {
|
|
356
|
+
const body = guardedEventStream(call.response.body, found.endpoint).pipeThrough(chatSseToAnthropic(clientModel));
|
|
357
|
+
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
|
|
358
|
+
}
|
|
359
|
+
const read = await readCompletion(found, call.response);
|
|
360
|
+
if (!read.ok)
|
|
361
|
+
return anthropicFailureResponse(read.failure);
|
|
362
|
+
const message = chatCompletionToAnthropic(read.payload, clientModel);
|
|
363
|
+
if (chat.stream !== true)
|
|
364
|
+
return Response.json(message);
|
|
365
|
+
// 要了流式、端点却回了整段 JSON:按 Anthropic 事件序列补成流
|
|
366
|
+
return new Response(anthropicMessageAsSse(message), { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
|
|
367
|
+
}
|
|
368
|
+
/** 一条完整的 Anthropic message → 等价的 SSE 事件序列(端点不支持流式时用)。 */
|
|
369
|
+
export function anthropicMessageAsSse(message) {
|
|
370
|
+
const frames = [];
|
|
371
|
+
const emit = (type, data) => frames.push(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
372
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
373
|
+
emit("message_start", { message: { ...message, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
374
|
+
content.forEach((block, index) => {
|
|
375
|
+
if (block.type === "thinking") {
|
|
376
|
+
emit("content_block_start", { index, content_block: { type: "thinking", thinking: "", signature: "" } });
|
|
377
|
+
emit("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } });
|
|
378
|
+
}
|
|
379
|
+
else if (block.type === "text") {
|
|
380
|
+
emit("content_block_start", { index, content_block: { type: "text", text: "" } });
|
|
381
|
+
emit("content_block_delta", { index, delta: { type: "text_delta", text: block.text } });
|
|
382
|
+
}
|
|
383
|
+
else if (block.type === "tool_use") {
|
|
384
|
+
emit("content_block_start", { index, content_block: { ...block, input: {} } });
|
|
385
|
+
emit("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input ?? {}) } });
|
|
386
|
+
}
|
|
387
|
+
emit("content_block_stop", { index });
|
|
388
|
+
});
|
|
389
|
+
emit("message_delta", { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: message.usage });
|
|
390
|
+
emit("message_stop", {});
|
|
391
|
+
return frames.join("");
|
|
392
|
+
}
|
|
393
|
+
async function serveResponses(match, found, deps) {
|
|
394
|
+
const { body: chat, notes } = responsesRequestToChat(match.body, reasoningFieldsFor(found.model));
|
|
395
|
+
const ctx = responsesContext(match.body, notes);
|
|
396
|
+
// 与网关 responses-route.ts 一样只做无状态子集
|
|
397
|
+
if (ctx.previousResponseId || ctx.conversation) {
|
|
398
|
+
return responsesFinalError({ code: "invalid_prompt", param: ctx.previousResponseId ? "previous_response_id" : "conversation" }, 400, tr("自定义端点只支持无状态请求:请在 input 里带上完整对话(store=false),不要用 previous_response_id / conversation。", "Custom endpoints are stateless: send the whole conversation in `input` (store=false) instead of previous_response_id / conversation."));
|
|
399
|
+
}
|
|
400
|
+
const call = await callEndpoint(found, chat, deps.sessionId, deps.signal, deps.fetchImpl);
|
|
401
|
+
if (!call.ok)
|
|
402
|
+
return responsesFailureResponse(call.failure, ctx);
|
|
403
|
+
if (chat.stream === true && isEventStream(call.response) && call.response.body) {
|
|
404
|
+
const guarded = guardedEventStream(call.response.body, found.endpoint);
|
|
405
|
+
return openAIResponseToResponses(new Response(guarded, { status: 200, headers: { "content-type": "text/event-stream" } }), ctx);
|
|
406
|
+
}
|
|
407
|
+
const read = await readCompletion(found, call.response);
|
|
408
|
+
if (!read.ok)
|
|
409
|
+
return responsesFailureResponse(read.failure, ctx);
|
|
410
|
+
if (chat.stream !== true)
|
|
411
|
+
return openAIResponseToResponses(Response.json(read.payload), ctx);
|
|
412
|
+
// 要了流式、端点却回了整段 JSON:把它当成只有一帧的 Chat 流,交给同一个流式转换器
|
|
413
|
+
const sse = `data: ${JSON.stringify(completionAsChunk(read.payload))}\n\ndata: [DONE]\n\n`;
|
|
414
|
+
return openAIResponseToResponses(new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }), ctx);
|
|
415
|
+
}
|
|
416
|
+
/** chat.completion → 等价的单个 chat.completion.chunk(message 换成 delta,工具调用补 index)。 */
|
|
417
|
+
export function completionAsChunk(payload) {
|
|
418
|
+
const choices = (Array.isArray(payload.choices) ? payload.choices : []).map((item) => {
|
|
419
|
+
const choice = asRecord(item) ?? {};
|
|
420
|
+
const message = asRecord(choice.message) ?? {};
|
|
421
|
+
const calls = Array.isArray(message.tool_calls)
|
|
422
|
+
? message.tool_calls.map((call, index) => ({ index, ...asRecord(call) }))
|
|
423
|
+
: undefined;
|
|
424
|
+
return { index: choice.index ?? 0, delta: { ...message, ...(calls ? { tool_calls: calls } : {}) }, finish_reason: choice.finish_reason ?? "stop" };
|
|
425
|
+
});
|
|
426
|
+
return { id: payload.id, object: "chat.completion.chunk", choices, ...(payload.usage ? { usage: payload.usage } : {}) };
|
|
427
|
+
}
|
|
428
|
+
/** 处理一个本地路由的请求,得到给客户端的 Web Response。 */
|
|
429
|
+
export async function customRouteResponse(match, input) {
|
|
430
|
+
const deps = {
|
|
431
|
+
sessionId: clientSessionId(input.headers),
|
|
432
|
+
signal: input.signal,
|
|
433
|
+
fetchImpl: input.fetchImpl ?? ((url, init) => fetch(url, init)),
|
|
434
|
+
};
|
|
435
|
+
if (match.lookup.kind === "missing") {
|
|
436
|
+
const failure = missingFailure(match.lookup);
|
|
437
|
+
if (match.protocol === "responses")
|
|
438
|
+
return responsesFailureResponse(failure, responsesContext(match.body, emptyNotes()));
|
|
439
|
+
return anthropicFailureResponse(failure);
|
|
440
|
+
}
|
|
441
|
+
const found = match.lookup.found;
|
|
442
|
+
return match.protocol === "responses" ? serveResponses(match, found, deps) : serveMessages(match, found, deps);
|
|
443
|
+
}
|
|
444
|
+
function emptyNotes() {
|
|
445
|
+
return { skippedItems: [], skippedTools: [], customTools: [], namespaces: [], aliases: {}, toolSearch: false };
|
|
446
|
+
}
|
|
447
|
+
/** Web Response → 本机 http 响应(流式逐块写)。 */
|
|
448
|
+
export async function writeWebResponse(res, response) {
|
|
449
|
+
const headers = {};
|
|
450
|
+
response.headers.forEach((value, name) => {
|
|
451
|
+
if (name !== "content-length" && name !== "transfer-encoding" && name !== "connection")
|
|
452
|
+
headers[name] = value;
|
|
453
|
+
});
|
|
454
|
+
res.writeHead(response.status, headers);
|
|
455
|
+
if (response.body) {
|
|
456
|
+
for await (const chunk of Readable.fromWeb(response.body)) {
|
|
457
|
+
res.write(chunk);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
res.end();
|
|
461
|
+
}
|
package/dist/device-auth.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { webcrypto } from "node:crypto";
|
|
2
2
|
import { type IncomingMessage } from "node:http";
|
|
3
|
-
import { type CliConfig } from "./config.js";
|
|
3
|
+
import { type CliConfig, type CustomEndpoint } from "./config.js";
|
|
4
|
+
import { type FetchLike } from "./custom-endpoint-proxy.js";
|
|
4
5
|
import { type GatewayFetchFailure } from "./gateway-route.js";
|
|
5
6
|
export declare function hasDeviceCredential(cfg: CliConfig): boolean;
|
|
6
7
|
export declare function generateDeviceKeyPair(): Promise<{
|
|
@@ -58,6 +59,16 @@ interface SigningProxy {
|
|
|
58
59
|
client: ClientSurface;
|
|
59
60
|
agent?: LaunchedAgent;
|
|
60
61
|
attestation: AttestationHolder;
|
|
62
|
+
customRoutes: CustomRoutes;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 自定义端点的本地路由(custom-endpoint-proxy.ts):model 是 `ep…/<模型>` 的 /v1/messages、
|
|
66
|
+
* /v1/responses 在本机转 Chat Completions 直连端点,不经网关。复用同一个代理时以最新一次传入的为准。
|
|
67
|
+
*/
|
|
68
|
+
export interface CustomRoutes {
|
|
69
|
+
endpoints: readonly CustomEndpoint[];
|
|
70
|
+
/** 测试注入;默认全局 fetch(与 Pi 直连自定义端点同一个 dispatcher) */
|
|
71
|
+
fetchImpl?: FetchLike;
|
|
61
72
|
}
|
|
62
73
|
/**
|
|
63
74
|
* Which client the launcher started behind this proxy. Sent as `x-u1s1-agent` /
|
|
@@ -104,4 +115,4 @@ export declare function onGatewayFetchFailure(listener: ((failure: GatewayFetchF
|
|
|
104
115
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
105
116
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
106
117
|
*/
|
|
107
|
-
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, attestationSource?: ClientAttestationSource, agent?: LaunchedAgent): Promise<SigningProxy>;
|
|
118
|
+
export declare function ensureSigningProxy(cfg: CliConfig, fallbackClient?: ClientSurface, attestationSource?: ClientAttestationSource, agent?: LaunchedAgent, customRoutes?: CustomRoutes): Promise<SigningProxy>;
|
package/dist/device-auth.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { arch, platform, release } from "node:os";
|
|
4
4
|
import { VERSION } from "./config.js";
|
|
5
|
+
import { customRouteResponse, matchCustomRoute, writeWebResponse } from "./custom-endpoint-proxy.js";
|
|
5
6
|
import { tr } from "./i18n.js";
|
|
6
7
|
import { describeFetchFailure, gatewayFetch, plainGatewaySender } from "./gateway-route.js";
|
|
7
8
|
const enc = new TextEncoder();
|
|
@@ -267,16 +268,20 @@ export function onGatewayFetchFailure(listener) {
|
|
|
267
268
|
* pi accepts static provider headers only. Keep it behind a loopback proxy that
|
|
268
269
|
* replaces the local bearer credential with a fresh DPoP proof per request.
|
|
269
270
|
*/
|
|
270
|
-
export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource, agent) {
|
|
271
|
+
export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource, agent, customRoutes) {
|
|
271
272
|
if (!hasDeviceCredential(cfg))
|
|
272
273
|
throw new Error(tr("当前安装需要重新登录,以创建设备凭证", "Please sign in again to create a device credential for this install."));
|
|
273
274
|
const client = clientSurface(fallbackClient);
|
|
274
275
|
const current = signingProxy;
|
|
275
276
|
if (current && current.token === cfg.deviceToken && current.client === client && sameAgent(current.agent, agent)) {
|
|
276
277
|
updateAttestationHolder(current.attestation, attestationSource);
|
|
278
|
+
if (customRoutes)
|
|
279
|
+
current.customRoutes = { ...customRoutes };
|
|
277
280
|
return current;
|
|
278
281
|
}
|
|
279
282
|
const attestation = {};
|
|
283
|
+
// 由下面的 signingProxy 对象持有(复用时会被换掉),请求处理时现取
|
|
284
|
+
let routesOf = () => customRoutes ?? { endpoints: [] };
|
|
280
285
|
updateAttestationHolder(attestation, attestationSource);
|
|
281
286
|
const localKey = `local-${randomBytes(32).toString("hex")}`;
|
|
282
287
|
const upstreamOrigin = new URL(cfg.baseUrl).origin;
|
|
@@ -308,6 +313,32 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attes
|
|
|
308
313
|
const pathAndQuery = `${localUrl.pathname}${localUrl.search}`;
|
|
309
314
|
try {
|
|
310
315
|
const body = await readSigningProxyRequestBody(req);
|
|
316
|
+
// 自定义端点的模型:本机转换后直连端点。到这里为止只用了本地 token 校验;下面网关那条路上的
|
|
317
|
+
// 设备凭证、DPoP、客户端证明、x-u1s1-* 头都不会碰到这个请求
|
|
318
|
+
const routes = routesOf();
|
|
319
|
+
const custom = matchCustomRoute(req.method, localUrl.pathname, body, routes.endpoints);
|
|
320
|
+
if (custom) {
|
|
321
|
+
try {
|
|
322
|
+
const response = await customRouteResponse(custom, {
|
|
323
|
+
headers: req.headers,
|
|
324
|
+
signal: upstreamAbort.signal,
|
|
325
|
+
...(routes.fetchImpl ? { fetchImpl: routes.fetchImpl } : {}),
|
|
326
|
+
});
|
|
327
|
+
await writeWebResponse(res, response);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
// 端点自己的失败已在 customRouteResponse 里转成客户端的错误形状;到这里的是客户端断开或
|
|
331
|
+
// 本机意外。不走下面「连不上 u1s1 服务器」那条(那是网关的话,也不上报网关连通性)
|
|
332
|
+
if (res.destroyed)
|
|
333
|
+
return;
|
|
334
|
+
if (res.headersSent)
|
|
335
|
+
return void res.destroy(error);
|
|
336
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
337
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
338
|
+
res.end(JSON.stringify({ error: { message: tr(`本机转发自定义端点请求时出错(${detail})`, `Local forwarding to the custom endpoint failed (${detail})`) } }));
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
311
342
|
const outboundHeaders = requestHeaders(req.headers);
|
|
312
343
|
// Set these at the final local hop so model calls and extension tools share
|
|
313
344
|
// exactly the same attribution, regardless of their upstream SDK defaults.
|
|
@@ -365,13 +396,16 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attes
|
|
|
365
396
|
});
|
|
366
397
|
server.unref();
|
|
367
398
|
const port = server.address().port;
|
|
368
|
-
|
|
399
|
+
const created = {
|
|
369
400
|
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
370
401
|
localKey,
|
|
371
402
|
token: cfg.deviceToken,
|
|
372
403
|
client,
|
|
373
404
|
...(agent ? { agent: { ...agent } } : {}),
|
|
374
405
|
attestation,
|
|
406
|
+
customRoutes: customRoutes ? { ...customRoutes } : { endpoints: [] },
|
|
375
407
|
};
|
|
408
|
+
routesOf = () => created.customRoutes;
|
|
409
|
+
signingProxy = created;
|
|
376
410
|
return signingProxy;
|
|
377
411
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
2
10
|
import { printConsoleBanner } from "./brand.js";
|
|
3
11
|
import { activeSite, loadConfig, pinLegacySiteBaseUrl, readSettings, VERSION } from "./config.js";
|
|
4
12
|
import { siteLinks, siteOf } from "./site.js";
|
|
@@ -13,7 +21,7 @@ import { isLauncherWord, launcherHelpRow, runLauncher } from "./launcher.js";
|
|
|
13
21
|
async function configureStartupHttp() {
|
|
14
22
|
const piEntry = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
15
23
|
const dispatcherUrl = new URL("./core/http-dispatcher.js", piEntry).href;
|
|
16
|
-
const dispatcher = await import(dispatcherUrl);
|
|
24
|
+
const dispatcher = await import(__rewriteRelativeImportExtension(dispatcherUrl));
|
|
17
25
|
dispatcher.applyHttpProxySettings(readSettings().httpProxy);
|
|
18
26
|
dispatcher.configureHttpDispatcher();
|
|
19
27
|
}
|
package/dist/launcher.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 全新用户首次运行出选择器并记住
|
|
7
7
|
* u1s1 use <客户端> 改默认
|
|
8
8
|
* u1s1 agents [pin|unpin|install …] 安装状态、版本、是否有更新;锁定版本;只下载不启动
|
|
9
|
+
* u1s1 agents models [--json] 各客户端可用的模型(带显示名),给 Hearth 等前端用;不登录、秒回
|
|
9
10
|
* u1s1 config [list|get|set|unset …] 用户可调的设置(默认客户端、各客户端默认模型、自动更新),见 config-command.ts
|
|
10
11
|
*
|
|
11
12
|
* claude / codex 是否可用完全由网关配置(/v1/agents/:agent/config 的 enabled)决定,这里不写死。
|
|
@@ -178,6 +179,12 @@ async function agentsCommand(args) {
|
|
|
178
179
|
process.exitCode = sub === "pin" ? await pinCommand(version, agent) : unpinCommand(agent);
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
182
|
+
if (sub === "models") {
|
|
183
|
+
// 不经 ensureAuth:未登录直接输出 loggedIn:false;联网只做短超时刷新,失败用缓存
|
|
184
|
+
const { modelsCommand } = await import("./agents/models-list.js");
|
|
185
|
+
process.exitCode = await modelsCommand(args.slice(1));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
181
188
|
if (sub === "install") {
|
|
182
189
|
if (agent === "pi" && version === undefined) {
|
|
183
190
|
console.log(tr("pi 随 u1s1 一起安装,不用单独下载。", "pi ships with u1s1; nothing to download."));
|
|
@@ -208,6 +215,7 @@ function agentsHelpLines() {
|
|
|
208
215
|
tr(" u1s1 agents install <客户端> 下载或更新到可用版本,不启动(claude / codex)", " u1s1 agents install <client> Download or update to a usable version without launching (claude / codex)"),
|
|
209
216
|
tr(" u1s1 agents pin <客户端> <版本> 锁定版本(不超出允许的范围)", " u1s1 agents pin <client> <ver> Pin a version (within the allowed range)"),
|
|
210
217
|
tr(" u1s1 agents unpin <客户端> 解除锁定,恢复自动更新", " u1s1 agents unpin <client> Remove the pin and resume automatic updates"),
|
|
218
|
+
tr(" u1s1 agents models [--json] 列出各客户端可用的模型(带显示名);--offline 只读本地缓存", " u1s1 agents models [--json] List each client's models with display names; --offline reads local caches only"),
|
|
211
219
|
];
|
|
212
220
|
}
|
|
213
221
|
// ---------------------------------------------------------------- help
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u1s1-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "u1s1 —
|
|
3
|
+
"version": "1.19.0",
|
|
4
|
+
"description": "u1s1 — an AI coding buddy in your terminal. Describe what you need; it reads files, edits code and runs commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"u1s1": "dist/index.js"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"u1s1"
|
|
40
40
|
],
|
|
41
41
|
"license": "MIT",
|
|
42
|
-
"homepage": "https://
|
|
42
|
+
"homepage": "https://u1s1.dev",
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@earendil-works/pi-agent-core": "1.5.2",
|
|
45
45
|
"@earendil-works/pi-ai": "1.5.2",
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
"typescript": "^5.9.3"
|
|
59
59
|
},
|
|
60
60
|
"u1s1": {
|
|
61
|
-
"defaultSite": "
|
|
61
|
+
"defaultSite": "intl"
|
|
62
62
|
}
|
|
63
63
|
}
|