waygate-core 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +88 -2
- package/dist/aggregate.d.ts +23 -0
- package/dist/aggregate.js +150 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.js +183 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +62 -0
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.js +57 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/report.d.ts +21 -0
- package/dist/report.js +52 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +160 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +7 -0
- package/package.json +35 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zhanghaojie513
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# waygate-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
**途关家族 · 共享骨架**
|
|
6
|
+
|
|
7
|
+
两个本地反代工具共用的那 80%
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
[waygate-qoder](https://github.com/zhanghaojie513/waygate-qoder) · [waygate-mmcode](https://github.com/zhanghaojie513/waygate-mmcode) · [适配器接口](#给适配器作者)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
**不面向最终用户** —— 这个包是两个反代工具共享的骨架,上游协议知识(凭证、翻译、模型清单)由各适配器实现 `Adapter` 接口。
|
|
20
|
+
|
|
21
|
+
它管的是每个本地反代**都一样**的部分:
|
|
22
|
+
|
|
23
|
+
- **本地 HTTP 服务** —— OpenAI / Anthropic 双端点、SSE 流原样透传、客户端断连即中止上游
|
|
24
|
+
- **流式与非流式** —— `stream: false` 时自动把 SSE 聚合成单个 `chat.completion` JSON(符合 OpenAI 规范)
|
|
25
|
+
- **入站密钥** —— 自动生成(`sk-<command>-…`)、落盘复用(客户端只需配一次)
|
|
26
|
+
- **配置目录** —— `~/.waygate/<command>/`,跨命令互不干扰
|
|
27
|
+
- **统一 CLI** —— `serve` / `models` / `doctor` / `login` 四个子命令,两家完全同构
|
|
28
|
+
- **扩展子命令** —— 适配器可注册自家专属命令(如 `mmcode checkin`),CLI 统一解析与分发
|
|
29
|
+
- **启动输出** —— 「凭证状态 + 代理地址 + 密钥 + 模型 ID」一屏内复制得走;`--json` 机器可读
|
|
30
|
+
- **自检** —— 端口占用 / 凭证 / 模型清单 / 上游连通,逐项给人话结论 + 下一步动作
|
|
31
|
+
|
|
32
|
+
## 为什么单独抽包
|
|
33
|
+
|
|
34
|
+
两个工具的命令面完全一致(`serve` / `models` / `doctor` / `login`),共用一套骨架意味着**改一处两家都受益**,而不是把同一段代码抄两遍(抄两遍的结局必然是只改一处)。
|
|
35
|
+
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart LR
|
|
38
|
+
subgraph adapters [适配器仓库]
|
|
39
|
+
Q[waygate-qoder<br/>COSY 签名 · DPAPI 解密]
|
|
40
|
+
M[waygate-mmcode<br/>Anthropic 兼容 · 明文 JSON]
|
|
41
|
+
end
|
|
42
|
+
C[waygate-core 包<br/>HTTP 服务 · 密钥 · CLI · 自检]
|
|
43
|
+
Q -->|Adapter 接口| C
|
|
44
|
+
M -->|Adapter 接口| C
|
|
45
|
+
|
|
46
|
+
style C fill:#EDE9FE,stroke:#8B5CF6,color:#1F2328
|
|
47
|
+
style Q fill:#FFFFFF,stroke:#D0D7DE,color:#1F2328
|
|
48
|
+
style M fill:#FFFFFF,stroke:#D0D7DE,color:#1F2328
|
|
49
|
+
style adapters fill:#F6F8FA,stroke:#D0D7DE
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 给适配器作者
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { runCli, type Adapter } from 'waygate-core';
|
|
56
|
+
|
|
57
|
+
const adapter: Adapter = {
|
|
58
|
+
command: 'qoc', // 命令名(决定 ~/.waygate/<command>/ 与 sk-<command>- 密钥前缀)
|
|
59
|
+
displayName: 'Qoder CN',
|
|
60
|
+
defaultPort: 8789,
|
|
61
|
+
upstreamLabel: 'https://gateway.qoder.com.cn',
|
|
62
|
+
credentialStatus: async () => ({ ok: true, detail: '…' }),
|
|
63
|
+
login: async () => { /* 导入客户端登录态 */ },
|
|
64
|
+
listModels: async () => [{ id: 'model-key', displayName: 'Model Name' }],
|
|
65
|
+
chat: async (body, signal) => ({ stream /* OpenAI 兼容 SSE */, status: 200 }),
|
|
66
|
+
doctorChecks: async () => [], // 可选:额外的自检项
|
|
67
|
+
|
|
68
|
+
/* 可选:本家专属子命令(core 负责解析、help 展示、分发) */
|
|
69
|
+
extraCommands: [{
|
|
70
|
+
name: 'checkin', summary: '每日签到领积分', usage: 'checkin',
|
|
71
|
+
run: async ({ json }) => (json ? '{}' : '✓ 签到成功'),
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
await runCli(adapter, { version: '0.1.0' });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
实现核心钩子,core 会替你处理 HTTP 服务、密钥管理、CLI 解析、启动输出、自检——你只写上游协议那一层。
|
|
78
|
+
|
|
79
|
+
> 安装:`npm i waygate-core`(仓库同时保留 `dist/`,供 GitHub 直装兜底)。
|
|
80
|
+
|
|
81
|
+
## 相关项目
|
|
82
|
+
|
|
83
|
+
- **[waygate-qoder](https://github.com/zhanghaojie513/waygate-qoder)** —— Qoder CN 本地反代(命令 `qoc`)
|
|
84
|
+
- **[waygate-mmcode](https://github.com/zhanghaojie513/waygate-mmcode)** —— MiniMax Code 本地反代(命令 `mmcode`)
|
|
85
|
+
- **[relay-station](https://github.com/zhanghaojie513/relay-station)** —— 同一作者的完整多提供商反代(含控制台、用量统计)
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 聚合器:把 OpenAI 形状的 SSE chunk 流拼成单个 `chat.completion` JSON。
|
|
3
|
+
*
|
|
4
|
+
* 用于 `stream: false` 的入站请求 —— 上游恒走流式(两家协议如此),代理在出口把
|
|
5
|
+
* 流聚合成非流式响应返回给客户端。这是 OpenAI 规范的要求:`stream:false` 应返回
|
|
6
|
+
* 单个 JSON 而非 SSE。
|
|
7
|
+
*
|
|
8
|
+
* 聚合规则(只认真实出现的字段,不编造):
|
|
9
|
+
* · `choices[0].delta.content` 拼接为 `message.content`
|
|
10
|
+
* · `choices[0].delta.reasoning_content` 拼接为 `message.reasoning_content`(DeepSeek 惯例)
|
|
11
|
+
* · `choices[0].delta.role` 取首个非空值
|
|
12
|
+
* · `choices[0].delta.tool_calls` 累积(按 index 合并 arguments 分片)
|
|
13
|
+
* · `finish_reason` 取最后一个非空值
|
|
14
|
+
* · `usage` 取末帧(含 `stream_options.include_usage` 的独立末帧)
|
|
15
|
+
* · `id` / `model` / `created` 取首个 chunk 的值
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* 聚合一整个 SSE 流为单个 completion 对象。
|
|
19
|
+
* 输入是 OpenAI SSE 字节流(与流式透传相同格式),输出是 JSON 字符串。
|
|
20
|
+
*
|
|
21
|
+
* @throws 如果流内不含任何有效 chunk(上游异常)
|
|
22
|
+
*/
|
|
23
|
+
export declare function aggregateSseToJson(stream: ReadableStream<Uint8Array>): Promise<string>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 聚合器:把 OpenAI 形状的 SSE chunk 流拼成单个 `chat.completion` JSON。
|
|
3
|
+
*
|
|
4
|
+
* 用于 `stream: false` 的入站请求 —— 上游恒走流式(两家协议如此),代理在出口把
|
|
5
|
+
* 流聚合成非流式响应返回给客户端。这是 OpenAI 规范的要求:`stream:false` 应返回
|
|
6
|
+
* 单个 JSON 而非 SSE。
|
|
7
|
+
*
|
|
8
|
+
* 聚合规则(只认真实出现的字段,不编造):
|
|
9
|
+
* · `choices[0].delta.content` 拼接为 `message.content`
|
|
10
|
+
* · `choices[0].delta.reasoning_content` 拼接为 `message.reasoning_content`(DeepSeek 惯例)
|
|
11
|
+
* · `choices[0].delta.role` 取首个非空值
|
|
12
|
+
* · `choices[0].delta.tool_calls` 累积(按 index 合并 arguments 分片)
|
|
13
|
+
* · `finish_reason` 取最后一个非空值
|
|
14
|
+
* · `usage` 取末帧(含 `stream_options.include_usage` 的独立末帧)
|
|
15
|
+
* · `id` / `model` / `created` 取首个 chunk 的值
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* 聚合一整个 SSE 流为单个 completion 对象。
|
|
19
|
+
* 输入是 OpenAI SSE 字节流(与流式透传相同格式),输出是 JSON 字符串。
|
|
20
|
+
*
|
|
21
|
+
* @throws 如果流内不含任何有效 chunk(上游异常)
|
|
22
|
+
*/
|
|
23
|
+
export async function aggregateSseToJson(stream) {
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
const agg = { content: '' };
|
|
26
|
+
let finish = null;
|
|
27
|
+
let usage;
|
|
28
|
+
let id = '';
|
|
29
|
+
let model = '';
|
|
30
|
+
let created = 0;
|
|
31
|
+
let sawChunk = false;
|
|
32
|
+
const reader = stream.getReader();
|
|
33
|
+
try {
|
|
34
|
+
let buf = '';
|
|
35
|
+
for (;;) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done)
|
|
38
|
+
break;
|
|
39
|
+
buf += decoder.decode(value, { stream: true });
|
|
40
|
+
/* SSE 帧按 \n\n 分隔;最后一段可能不完整(等下一块)。 */
|
|
41
|
+
const frames = buf.split('\n\n');
|
|
42
|
+
buf = frames.pop() ?? '';
|
|
43
|
+
for (const frame of frames) {
|
|
44
|
+
const dataLine = frame
|
|
45
|
+
.split('\n')
|
|
46
|
+
.find((l) => l.startsWith('data:'));
|
|
47
|
+
if (!dataLine)
|
|
48
|
+
continue;
|
|
49
|
+
const payload = dataLine.slice(5).trim();
|
|
50
|
+
if (!payload || payload === '[DONE]')
|
|
51
|
+
continue;
|
|
52
|
+
let chunk;
|
|
53
|
+
try {
|
|
54
|
+
chunk = JSON.parse(payload);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue; /* 非 JSON 帧(上游杂音),跳过 */
|
|
58
|
+
}
|
|
59
|
+
sawChunk = true;
|
|
60
|
+
if (!id && typeof chunk.id === 'string')
|
|
61
|
+
id = chunk.id;
|
|
62
|
+
if (!model && typeof chunk.model === 'string')
|
|
63
|
+
model = chunk.model;
|
|
64
|
+
if (!created && typeof chunk.created === 'number')
|
|
65
|
+
created = chunk.created;
|
|
66
|
+
/* usage 独立末帧(choices 为空)或挂在内容帧上 —— 都取。 */
|
|
67
|
+
const u = chunk.usage;
|
|
68
|
+
if (u && typeof u === 'object')
|
|
69
|
+
usage = u;
|
|
70
|
+
const choices = chunk.choices;
|
|
71
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
const c = choices[0];
|
|
74
|
+
const fr = c.finish_reason;
|
|
75
|
+
if (typeof fr === 'string' && fr)
|
|
76
|
+
finish = fr;
|
|
77
|
+
const delta = c.delta;
|
|
78
|
+
if (!delta || typeof delta !== 'object')
|
|
79
|
+
continue;
|
|
80
|
+
const d = delta;
|
|
81
|
+
if (typeof d.role === 'string' && d.role && !agg.role)
|
|
82
|
+
agg.role = d.role;
|
|
83
|
+
if (typeof d.content === 'string')
|
|
84
|
+
agg.content += d.content;
|
|
85
|
+
if (typeof d.reasoning_content === 'string') {
|
|
86
|
+
agg.reasoning_content = (agg.reasoning_content ?? '') + d.reasoning_content;
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(d.tool_calls)) {
|
|
89
|
+
agg.tool_calls = agg.tool_calls ?? [];
|
|
90
|
+
for (const tc of d.tool_calls) {
|
|
91
|
+
if (!tc || typeof tc !== 'object')
|
|
92
|
+
continue;
|
|
93
|
+
const t = tc;
|
|
94
|
+
const idx = typeof t.index === 'number' ? t.index : 0;
|
|
95
|
+
const fn = (t.function ?? {});
|
|
96
|
+
const existing = agg.tool_calls.find((x) => x.index === idx);
|
|
97
|
+
if (existing) {
|
|
98
|
+
if (typeof fn.name === 'string' && fn.name)
|
|
99
|
+
existing.function.name = fn.name;
|
|
100
|
+
if (typeof fn.arguments === 'string')
|
|
101
|
+
existing.function.arguments += fn.arguments;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
agg.tool_calls.push({
|
|
105
|
+
index: idx,
|
|
106
|
+
id: typeof t.id === 'string' ? t.id : `call_${idx}`,
|
|
107
|
+
type: typeof t.type === 'string' ? t.type : 'function',
|
|
108
|
+
function: {
|
|
109
|
+
name: typeof fn.name === 'string' ? fn.name : '',
|
|
110
|
+
arguments: typeof fn.arguments === 'string' ? fn.arguments : '',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
}
|
|
122
|
+
if (!sawChunk)
|
|
123
|
+
throw new Error('上游流不含有效 chunk(聚合失败)');
|
|
124
|
+
/* tool_calls 的 index 在最终响应中不出现(OpenAI 规范)。 */
|
|
125
|
+
const toolCalls = agg.tool_calls?.map((tc) => ({
|
|
126
|
+
id: tc.id ?? '',
|
|
127
|
+
type: tc.type ?? 'function',
|
|
128
|
+
function: { name: tc.function?.name ?? '', arguments: tc.function?.arguments ?? '' },
|
|
129
|
+
}));
|
|
130
|
+
const completion = {
|
|
131
|
+
id: id || 'chatcmpl-unknown',
|
|
132
|
+
object: 'chat.completion',
|
|
133
|
+
created: created || Math.floor(Date.now() / 1000),
|
|
134
|
+
model: model || 'unknown',
|
|
135
|
+
choices: [
|
|
136
|
+
{
|
|
137
|
+
index: 0,
|
|
138
|
+
message: {
|
|
139
|
+
role: agg.role ?? 'assistant',
|
|
140
|
+
content: agg.content,
|
|
141
|
+
...(agg.reasoning_content !== undefined ? { reasoning_content: agg.reasoning_content } : {}),
|
|
142
|
+
...(toolCalls && toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
143
|
+
},
|
|
144
|
+
finish_reason: finish,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
...(usage ? { usage } : {}),
|
|
148
|
+
};
|
|
149
|
+
return JSON.stringify(completion);
|
|
150
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 CLI(两家命令共用这套子命令:serve / models / doctor / login)。
|
|
3
|
+
*
|
|
4
|
+
* 设计目标:**学一个会两个** —— `qoc` 与 `mmcode` 的子命令、参数完全一致,
|
|
5
|
+
* 只有命令名不同。适配器只提供业务钩子(凭证/模型/聊天),CLI 由这里统一处理。
|
|
6
|
+
*/
|
|
7
|
+
import { maskKey } from './config.js';
|
|
8
|
+
import type { Adapter } from './types.js';
|
|
9
|
+
export interface CliMeta {
|
|
10
|
+
/** 包版本(各仓 package.json 的 version)。 */
|
|
11
|
+
version: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ParsedArgs {
|
|
14
|
+
/** 内置子命令,或适配器自定义子命令名。 */
|
|
15
|
+
command: 'serve' | 'models' | 'doctor' | 'login' | 'help' | (string & {});
|
|
16
|
+
host: string;
|
|
17
|
+
port?: number;
|
|
18
|
+
key?: string;
|
|
19
|
+
json: boolean;
|
|
20
|
+
verbose: boolean;
|
|
21
|
+
/** 传给自定义子命令的剩余位置参数。 */
|
|
22
|
+
rest: string[];
|
|
23
|
+
}
|
|
24
|
+
export declare function parseArgs(argv: string[], adapter: Adapter): ParsedArgs;
|
|
25
|
+
export declare function helpText(adapter: Adapter): string;
|
|
26
|
+
/** 命令入口:适配器 + 版本 → 完整 CLI 行为。返回进程退出码。 */
|
|
27
|
+
export declare function runCli(adapter: Adapter, meta: CliMeta, argv?: string[]): Promise<number>;
|
|
28
|
+
export { maskKey };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 CLI(两家命令共用这套子命令:serve / models / doctor / login)。
|
|
3
|
+
*
|
|
4
|
+
* 设计目标:**学一个会两个** —— `qoc` 与 `mmcode` 的子命令、参数完全一致,
|
|
5
|
+
* 只有命令名不同。适配器只提供业务钩子(凭证/模型/聊天),CLI 由这里统一处理。
|
|
6
|
+
*/
|
|
7
|
+
import { resolveKey, maskKey } from './config.js';
|
|
8
|
+
import { runDoctor, renderDoctor } from './doctor.js';
|
|
9
|
+
import { renderReady, renderReadyJson } from './report.js';
|
|
10
|
+
import { startServer } from './server.js';
|
|
11
|
+
const BUILTIN = ['serve', 'models', 'doctor', 'login', 'help', '--help', '-h'];
|
|
12
|
+
export function parseArgs(argv, adapter) {
|
|
13
|
+
const args = [...argv];
|
|
14
|
+
const known = new Set([...BUILTIN, ...(adapter.extraCommands ?? []).map((c) => c.name)]);
|
|
15
|
+
let command = 'serve';
|
|
16
|
+
if (args[0] && known.has(args[0])) {
|
|
17
|
+
const c = args.shift();
|
|
18
|
+
command = c === '--help' || c === '-h' ? 'help' : c;
|
|
19
|
+
}
|
|
20
|
+
let host = '127.0.0.1';
|
|
21
|
+
let port;
|
|
22
|
+
let key;
|
|
23
|
+
let json = false;
|
|
24
|
+
let verbose = false;
|
|
25
|
+
const rest = [];
|
|
26
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
27
|
+
const a = args[i];
|
|
28
|
+
if (a === undefined)
|
|
29
|
+
break;
|
|
30
|
+
if (a === '--host')
|
|
31
|
+
host = args[++i] ?? host;
|
|
32
|
+
else if (a === '--port') {
|
|
33
|
+
const v = Number(args[++i]);
|
|
34
|
+
if (!Number.isInteger(v) || v < 1 || v > 65535)
|
|
35
|
+
throw new Error(`--port 非法:${args[i]}`);
|
|
36
|
+
port = v;
|
|
37
|
+
}
|
|
38
|
+
else if (a === '--key')
|
|
39
|
+
key = args[++i];
|
|
40
|
+
else if (a === '--json')
|
|
41
|
+
json = true;
|
|
42
|
+
else if (a === '--verbose')
|
|
43
|
+
verbose = true;
|
|
44
|
+
else if (a === '--help' || a === '-h')
|
|
45
|
+
command = 'help';
|
|
46
|
+
else if (a.startsWith('-'))
|
|
47
|
+
throw new Error(`未知参数:${a}(用 --help 看用法)`);
|
|
48
|
+
else
|
|
49
|
+
rest.push(a); /* 自定义子命令的位置参数(如 checkin --all) */
|
|
50
|
+
}
|
|
51
|
+
return { command, host, port, key, json, verbose, rest };
|
|
52
|
+
}
|
|
53
|
+
export function helpText(adapter) {
|
|
54
|
+
const c = adapter.command;
|
|
55
|
+
return [
|
|
56
|
+
`途关 · ${adapter.displayName} 反代(命令 ${c})`,
|
|
57
|
+
'',
|
|
58
|
+
'用法:',
|
|
59
|
+
` ${c} 启动反代(同 ${c} serve),打印模型 ID 与代理地址`,
|
|
60
|
+
` ${c} serve 同上`,
|
|
61
|
+
` ${c} models 只列出模型 ID(不起服务)`,
|
|
62
|
+
` ${c} doctor 自检:端口 / 凭证 / 模型 / 上游连通`,
|
|
63
|
+
` ${c} login 导入或刷新本机 ${adapter.displayName} 客户端的登录态`,
|
|
64
|
+
...(adapter.extraCommands?.length
|
|
65
|
+
? ['', `${adapter.displayName} 专属:`, ...adapter.extraCommands.map((x) => ` ${c} ${x.usage.padEnd(17)}${x.summary}`)]
|
|
66
|
+
: []),
|
|
67
|
+
'',
|
|
68
|
+
'参数:',
|
|
69
|
+
' --port <n> 监听端口(默认 ' + adapter.defaultPort + ')',
|
|
70
|
+
' --host <addr> 监听地址(默认 127.0.0.1;0.0.0.0 会暴露给局域网,谨慎)',
|
|
71
|
+
' --key <sk-…> 指定入站密钥(默认自动生成并缓存到 ~/.waygate/' + c + '/key)',
|
|
72
|
+
' --json 机器可读输出',
|
|
73
|
+
' --verbose 打印每个请求的路由日志',
|
|
74
|
+
' --help 显示本帮助',
|
|
75
|
+
'',
|
|
76
|
+
'只读取你本机的客户端登录态、在上游与客户端之间本地转发;不上传任何数据。',
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
/** 命令入口:适配器 + 版本 → 完整 CLI 行为。返回进程退出码。 */
|
|
80
|
+
export async function runCli(adapter, meta, argv = process.argv.slice(2)) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = parseArgs(argv, adapter);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
process.stderr.write(`${err.message}\n`);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
if (parsed.command === 'help') {
|
|
90
|
+
process.stdout.write(helpText(adapter) + '\n');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
/* 适配器自定义子命令(如 mmcode 的 checkin / credits)。 */
|
|
94
|
+
const extra = adapter.extraCommands?.find((x) => x.name === parsed.command);
|
|
95
|
+
if (extra) {
|
|
96
|
+
try {
|
|
97
|
+
const out = await extra.run({ json: parsed.json, args: parsed.rest });
|
|
98
|
+
if (out)
|
|
99
|
+
process.stdout.write(out.endsWith('\n') ? out : out + '\n');
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
process.stderr.write(`${extra.name} 失败:${err.message}\n`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (parsed.command === 'login') {
|
|
108
|
+
try {
|
|
109
|
+
await adapter.login();
|
|
110
|
+
const cred = await adapter.credentialStatus();
|
|
111
|
+
process.stdout.write(`凭证 ${cred.ok ? '✓' : '✗'} ${cred.detail}\n`);
|
|
112
|
+
return cred.ok ? 0 : 1;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
process.stderr.write(`登录态导入失败:${err.message}\n`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const host = parsed.host;
|
|
120
|
+
const port = parsed.port ?? adapter.defaultPort;
|
|
121
|
+
if (parsed.command === 'doctor') {
|
|
122
|
+
const results = await runDoctor(adapter, host, port);
|
|
123
|
+
process.stdout.write(renderDoctor(results) + '\n');
|
|
124
|
+
return results.every((r) => r.ok) ? 0 : 1;
|
|
125
|
+
}
|
|
126
|
+
const key = resolveKey(adapter.command, parsed.key);
|
|
127
|
+
const cred = await adapter.credentialStatus();
|
|
128
|
+
const models = await adapter.listModels().catch(() => []);
|
|
129
|
+
if (parsed.command === 'models') {
|
|
130
|
+
if (parsed.json) {
|
|
131
|
+
process.stdout.write(JSON.stringify({ models: models.map((m) => ({ id: m.id, displayName: m.displayName ?? null })) }, null, 2) + '\n');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
for (const m of models)
|
|
135
|
+
process.stdout.write(`${m.id}${m.displayName && m.displayName !== m.id ? ' ' + m.displayName : ''}\n`);
|
|
136
|
+
}
|
|
137
|
+
return models.length > 0 ? 0 : 1;
|
|
138
|
+
}
|
|
139
|
+
/* serve */
|
|
140
|
+
const ready = {
|
|
141
|
+
command: adapter.command,
|
|
142
|
+
displayName: adapter.displayName,
|
|
143
|
+
version: meta.version,
|
|
144
|
+
upstream: adapter.upstreamLabel,
|
|
145
|
+
host,
|
|
146
|
+
port,
|
|
147
|
+
key,
|
|
148
|
+
credential: cred,
|
|
149
|
+
models,
|
|
150
|
+
};
|
|
151
|
+
if (!cred.ok && !parsed.json) {
|
|
152
|
+
process.stderr.write(`凭证未就绪:${cred.detail}\n先运行:${adapter.command} login\n`);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const running = await startServer(adapter, {
|
|
156
|
+
host,
|
|
157
|
+
port,
|
|
158
|
+
apiKey: key,
|
|
159
|
+
verbose: parsed.verbose,
|
|
160
|
+
log: (line) => process.stdout.write(line + '\n'),
|
|
161
|
+
});
|
|
162
|
+
process.stdout.write((parsed.json ? renderReadyJson({ ...ready, port: running.port }) : renderReady({ ...ready, port: running.port })) + '\n');
|
|
163
|
+
const shutdown = () => {
|
|
164
|
+
process.stdout.write('\n正在停止…\n');
|
|
165
|
+
void running.close().then(() => process.exit(0));
|
|
166
|
+
};
|
|
167
|
+
process.on('SIGINT', shutdown);
|
|
168
|
+
process.on('SIGTERM', shutdown);
|
|
169
|
+
return await new Promise(() => {
|
|
170
|
+
/* 常驻:直到信号触发 exit */
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
const e = err;
|
|
175
|
+
if (e.code === 'EADDRINUSE') {
|
|
176
|
+
process.stderr.write(`端口 ${port} 已被占用 —— 换一个:${adapter.command} --port ${port + 1}\n`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
process.stderr.write(`启动失败:${e.message}\n`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export { maskKey };
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置目录与入站密钥管理。
|
|
3
|
+
*
|
|
4
|
+
* 配置落在用户主目录下的 `.waygate/`(跨平台):
|
|
5
|
+
* ~/.waygate/<command>/ 每家一个子目录(qoc / mmcode 互不干扰)
|
|
6
|
+
* ~/.waygate/<command>/key 入站密钥(sk-<command>-…,0600)
|
|
7
|
+
* ~/.waygate/<command>/credential.json 适配器自管的凭证副本(可选)
|
|
8
|
+
*
|
|
9
|
+
* 为什么密钥要落盘:客户端(Cursor / Claude Code 等)只接受**稳定的**密钥串;
|
|
10
|
+
* 每次启动随机生成会让用户在客户端里反复改配置。落盘一次、后续复用。
|
|
11
|
+
*/
|
|
12
|
+
/** 家族配置根目录(`~/.waygate`)。可用 WAYGATE_HOME 覆盖(测试/便携部署用)。 */
|
|
13
|
+
export declare function waygateHome(): string;
|
|
14
|
+
/** 某家命令的配置目录(`~/.waygate/<command>`),确保存在。 */
|
|
15
|
+
export declare function commandDir(command: string): string;
|
|
16
|
+
/** 入站密钥的落盘形态:`sk-<command>-<32hex>`。 */
|
|
17
|
+
export declare function generateKey(command: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* 取该命令的入站密钥:已落盘则复用,否则生成并落盘(0600)。
|
|
20
|
+
* `override`(CLI `--key`)优先,不落盘 —— 用户显式指定即由用户自管。
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveKey(command: string, override?: string): string;
|
|
23
|
+
/** 打印用:密钥打码(只留头尾)。 */
|
|
24
|
+
export declare function maskKey(key: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置目录与入站密钥管理。
|
|
3
|
+
*
|
|
4
|
+
* 配置落在用户主目录下的 `.waygate/`(跨平台):
|
|
5
|
+
* ~/.waygate/<command>/ 每家一个子目录(qoc / mmcode 互不干扰)
|
|
6
|
+
* ~/.waygate/<command>/key 入站密钥(sk-<command>-…,0600)
|
|
7
|
+
* ~/.waygate/<command>/credential.json 适配器自管的凭证副本(可选)
|
|
8
|
+
*
|
|
9
|
+
* 为什么密钥要落盘:客户端(Cursor / Claude Code 等)只接受**稳定的**密钥串;
|
|
10
|
+
* 每次启动随机生成会让用户在客户端里反复改配置。落盘一次、后续复用。
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** 家族配置根目录(`~/.waygate`)。可用 WAYGATE_HOME 覆盖(测试/便携部署用)。 */
|
|
17
|
+
export function waygateHome() {
|
|
18
|
+
const override = (process.env.WAYGATE_HOME ?? '').trim();
|
|
19
|
+
return override || join(homedir(), '.waygate');
|
|
20
|
+
}
|
|
21
|
+
/** 某家命令的配置目录(`~/.waygate/<command>`),确保存在。 */
|
|
22
|
+
export function commandDir(command) {
|
|
23
|
+
const dir = join(waygateHome(), command);
|
|
24
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
/** 入站密钥的落盘形态:`sk-<command>-<32hex>`。 */
|
|
28
|
+
export function generateKey(command) {
|
|
29
|
+
return `sk-${command}-${randomBytes(16).toString('hex')}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 取该命令的入站密钥:已落盘则复用,否则生成并落盘(0600)。
|
|
33
|
+
* `override`(CLI `--key`)优先,不落盘 —— 用户显式指定即由用户自管。
|
|
34
|
+
*/
|
|
35
|
+
export function resolveKey(command, override) {
|
|
36
|
+
if (override && override.trim())
|
|
37
|
+
return override.trim();
|
|
38
|
+
const file = join(commandDir(command), 'key');
|
|
39
|
+
try {
|
|
40
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
41
|
+
if (existing)
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* 首次:生成 */
|
|
46
|
+
}
|
|
47
|
+
const key = generateKey(command);
|
|
48
|
+
writeFileSync(file, key + '\n', { mode: 0o600 });
|
|
49
|
+
try {
|
|
50
|
+
chmodSync(file, 0o600);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* Windows 上 chmod 语义有限,忽略 */
|
|
54
|
+
}
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
/** 打印用:密钥打码(只留头尾)。 */
|
|
58
|
+
export function maskKey(key) {
|
|
59
|
+
if (key.length <= 14)
|
|
60
|
+
return key.slice(0, 4) + '…';
|
|
61
|
+
return `${key.slice(0, 12)}…${key.slice(-4)}`;
|
|
62
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自检(`doctor` 子命令):把「为什么用不了」拆成可逐项排查的检查清单。
|
|
3
|
+
*
|
|
4
|
+
* 顺序按「用户能自己解决」靠前:端口占用 → 凭证 → 模型 → 上游连通。
|
|
5
|
+
* 每项都给人话结论 + 下一步动作(不抛栈)。
|
|
6
|
+
*/
|
|
7
|
+
import type { Adapter } from './types.js';
|
|
8
|
+
export interface CheckResult {
|
|
9
|
+
name: string;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
/** 端口是否可用(能监听即视为可用)。 */
|
|
14
|
+
export declare function checkPortFree(host: string, port: number): Promise<CheckResult>;
|
|
15
|
+
/** 跑完整自检(适配器提供凭证/模型/上游项)。 */
|
|
16
|
+
export declare function runDoctor(adapter: Adapter, host: string, port: number): Promise<CheckResult[]>;
|
|
17
|
+
export declare function renderDoctor(results: CheckResult[]): string;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自检(`doctor` 子命令):把「为什么用不了」拆成可逐项排查的检查清单。
|
|
3
|
+
*
|
|
4
|
+
* 顺序按「用户能自己解决」靠前:端口占用 → 凭证 → 模型 → 上游连通。
|
|
5
|
+
* 每项都给人话结论 + 下一步动作(不抛栈)。
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
/** 端口是否可用(能监听即视为可用)。 */
|
|
9
|
+
export async function checkPortFree(host, port) {
|
|
10
|
+
const bindHost = host === '0.0.0.0' || host === '::' ? '0.0.0.0' : host;
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const probe = createServer();
|
|
13
|
+
probe.once('error', (err) => {
|
|
14
|
+
resolve({
|
|
15
|
+
name: `端口 ${port}`,
|
|
16
|
+
ok: false,
|
|
17
|
+
detail: err.code === 'EADDRINUSE'
|
|
18
|
+
? `已被占用 —— 换一个端口(--port ${port + 1}),或先停掉占用该端口的程序`
|
|
19
|
+
: `无法监听:${err.message}`,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
probe.listen(port, bindHost, () => {
|
|
23
|
+
probe.close(() => resolve({ name: `端口 ${port}`, ok: true, detail: '可用' }));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** 跑完整自检(适配器提供凭证/模型/上游项)。 */
|
|
28
|
+
export async function runDoctor(adapter, host, port) {
|
|
29
|
+
const results = [];
|
|
30
|
+
results.push(await checkPortFree(host, port));
|
|
31
|
+
const cred = await adapter.credentialStatus();
|
|
32
|
+
results.push({ name: '凭证', ok: cred.ok, detail: cred.detail + (cred.source ? `(${cred.source})` : '') });
|
|
33
|
+
if (cred.ok) {
|
|
34
|
+
try {
|
|
35
|
+
const models = await adapter.listModels();
|
|
36
|
+
results.push({
|
|
37
|
+
name: '模型清单',
|
|
38
|
+
ok: models.length > 0,
|
|
39
|
+
detail: models.length > 0 ? `${models.length} 个:${models.slice(0, 3).map((m) => m.id).join('、')}${models.length > 3 ? ' …' : ''}` : '拉不到模型 —— 上游可能改了接口,或凭证已失效',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
results.push({ name: '模型清单', ok: false, detail: err.message });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (adapter.doctorChecks) {
|
|
47
|
+
results.push(...(await adapter.doctorChecks()));
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
export function renderDoctor(results) {
|
|
52
|
+
const lines = results.map((r) => `${r.ok ? '✓' : '✗'} ${r.name} ${r.detail}`);
|
|
53
|
+
const allOk = results.every((r) => r.ok);
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push(allOk ? '全部通过 —— 直接运行对应命令即可启动反代。' : '有项目未通过 —— 按上面的提示处理后重跑 doctor。');
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @waygate/core —— Waygate 家族共享骨架。
|
|
3
|
+
*
|
|
4
|
+
* 提供:本地反代 HTTP 服务、入站密钥、配置目录、启动输出、自检、统一 CLI。
|
|
5
|
+
* 上游协议知识(凭证、翻译、模型清单)由各适配器仓库实现 `Adapter` 接口。
|
|
6
|
+
*/
|
|
7
|
+
export type { Adapter, ChatMessage, ChatRequestBody, CredentialStatus, ModelEntry, UpstreamCallResult, } from './types.js';
|
|
8
|
+
export { waygateHome, commandDir, resolveKey, generateKey, maskKey } from './config.js';
|
|
9
|
+
export { startServer, type ServeOptions, type RunningServer } from './server.js';
|
|
10
|
+
export { aggregateSseToJson } from './aggregate.js';
|
|
11
|
+
export { renderReady, renderReadyJson, type ReadyReport } from './report.js';
|
|
12
|
+
export { runDoctor, renderDoctor, checkPortFree, type CheckResult } from './doctor.js';
|
|
13
|
+
export { runCli, parseArgs, helpText, type CliMeta, type ParsedArgs } from './cli.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @waygate/core —— Waygate 家族共享骨架。
|
|
3
|
+
*
|
|
4
|
+
* 提供:本地反代 HTTP 服务、入站密钥、配置目录、启动输出、自检、统一 CLI。
|
|
5
|
+
* 上游协议知识(凭证、翻译、模型清单)由各适配器仓库实现 `Adapter` 接口。
|
|
6
|
+
*/
|
|
7
|
+
export { waygateHome, commandDir, resolveKey, generateKey, maskKey } from './config.js';
|
|
8
|
+
export { startServer } from './server.js';
|
|
9
|
+
export { aggregateSseToJson } from './aggregate.js';
|
|
10
|
+
export { renderReady, renderReadyJson } from './report.js';
|
|
11
|
+
export { runDoctor, renderDoctor, checkPortFree } from './doctor.js';
|
|
12
|
+
export { runCli, parseArgs, helpText } from './cli.js';
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动输出(`serve` / `models` 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 人读版:模型 ID + 代理地址 + 密钥,一屏内复制得走。
|
|
5
|
+
* `--json`:同信息机器可读(给脚本/其他工具消费)。
|
|
6
|
+
*/
|
|
7
|
+
import type { CredentialStatus, ModelEntry } from './types.js';
|
|
8
|
+
export interface ReadyReport {
|
|
9
|
+
command: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
version: string;
|
|
12
|
+
adapterVersion?: string;
|
|
13
|
+
upstream: string;
|
|
14
|
+
host: string;
|
|
15
|
+
port: number;
|
|
16
|
+
key: string;
|
|
17
|
+
credential: CredentialStatus;
|
|
18
|
+
models: ModelEntry[];
|
|
19
|
+
}
|
|
20
|
+
export declare function renderReady(r: ReadyReport): string;
|
|
21
|
+
export declare function renderReadyJson(r: ReadyReport): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动输出(`serve` / `models` 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 人读版:模型 ID + 代理地址 + 密钥,一屏内复制得走。
|
|
5
|
+
* `--json`:同信息机器可读(给脚本/其他工具消费)。
|
|
6
|
+
*/
|
|
7
|
+
import { maskKey } from './config.js';
|
|
8
|
+
/** 代理地址(回环时省略 scheme 中的主机歧义,直接用用户可复制的完整 URL)。 */
|
|
9
|
+
function baseUrl(host, port) {
|
|
10
|
+
const h = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
11
|
+
return `http://${h}:${port}`;
|
|
12
|
+
}
|
|
13
|
+
export function renderReady(r) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(`途关 · ${r.displayName} 反代 v${r.version}`);
|
|
16
|
+
lines.push('');
|
|
17
|
+
lines.push(`凭证 ${r.credential.ok ? '✓' : '✗'} ${r.credential.detail}`);
|
|
18
|
+
lines.push(`上游 ${r.upstream}`);
|
|
19
|
+
lines.push(`代理 ${baseUrl(r.host, r.port)}`);
|
|
20
|
+
lines.push(`密钥 ${maskKey(r.key)}`);
|
|
21
|
+
if (r.host === '0.0.0.0' || r.host === '::') {
|
|
22
|
+
lines.push('');
|
|
23
|
+
lines.push(`⚠️ 正在监听所有网络接口(${r.host})—— 局域网内任何设备都能访问你的订阅。`);
|
|
24
|
+
lines.push(' 如非必要,请改用默认的 127.0.0.1。');
|
|
25
|
+
}
|
|
26
|
+
lines.push('');
|
|
27
|
+
lines.push('模型 ID(填进你的客户端):');
|
|
28
|
+
if (r.models.length === 0)
|
|
29
|
+
lines.push(' (暂无 —— 先跑一次 doctor 排查)');
|
|
30
|
+
for (const m of r.models) {
|
|
31
|
+
const hint = m.displayName && m.displayName !== m.id ? ` ${m.displayName}` : '';
|
|
32
|
+
lines.push(` ${m.id}${hint}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push(`把上面的「代理地址 + 密钥」填进 Cursor / Claude Code / Cherry Studio 等客户端即可。`);
|
|
36
|
+
lines.push('端点:POST /v1/chat/completions(OpenAI 兼容)· POST /v1/messages(Anthropic 兼容)· GET /v1/models');
|
|
37
|
+
lines.push('按 Ctrl+C 停止。');
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
export function renderReadyJson(r) {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
command: r.command,
|
|
43
|
+
displayName: r.displayName,
|
|
44
|
+
version: r.version,
|
|
45
|
+
upstream: r.upstream,
|
|
46
|
+
proxyUrl: baseUrl(r.host, r.port),
|
|
47
|
+
key: r.key,
|
|
48
|
+
credential: { ok: r.credential.ok, detail: r.credential.detail, source: r.credential.source ?? null },
|
|
49
|
+
models: r.models.map((m) => ({ id: m.id, displayName: m.displayName ?? null })),
|
|
50
|
+
endpoints: { openai: '/v1/chat/completions', anthropic: '/v1/messages', models: '/v1/models' },
|
|
51
|
+
}, null, 2);
|
|
52
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地反代 HTTP 服务骨架。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(刻意收窄):只做「收请求 → 交给适配器 → 把 SSE 流原样吐回」。
|
|
5
|
+
* 协议翻译、凭证、模型清单全在**适配器**里 —— core 不含任何上游知识,
|
|
6
|
+
* 所以两家适配器共用这一个服务实现,改一处两家都受益。
|
|
7
|
+
*
|
|
8
|
+
* 暴露的端点(与 OpenAI / Anthropic 客户端约定一致):
|
|
9
|
+
* POST /v1/chat/completions OpenAI 兼容聊天(Cursor / Cherry Studio 等)
|
|
10
|
+
* POST /v1/messages Anthropic 兼容聊天(Claude Code 等)
|
|
11
|
+
* GET /v1/models 模型列表(OpenAI 形状)
|
|
12
|
+
* GET /healthz 健康检查(无鉴权,供探活)
|
|
13
|
+
*
|
|
14
|
+
* 安全默认:只监听回环地址;`--host 0.0.0.0` 需显式指定并打印警告。
|
|
15
|
+
*/
|
|
16
|
+
import type { Adapter } from './types.js';
|
|
17
|
+
export interface ServeOptions {
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
/** 入站密钥;请求需带 `Authorization: Bearer <key>` 或 `x-api-key: <key>`。 */
|
|
21
|
+
apiKey: string;
|
|
22
|
+
verbose?: boolean;
|
|
23
|
+
/** 请求日志(默认 stdout)。 */
|
|
24
|
+
log?: (line: string) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface RunningServer {
|
|
27
|
+
port: number;
|
|
28
|
+
/** 关停(等现有连接结束)。 */
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 启动服务。适配器提供协议侧的一切(凭证、翻译、流)。
|
|
33
|
+
*/
|
|
34
|
+
export declare function startServer(adapter: Adapter, opts: ServeOptions): Promise<RunningServer>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地反代 HTTP 服务骨架。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(刻意收窄):只做「收请求 → 交给适配器 → 把 SSE 流原样吐回」。
|
|
5
|
+
* 协议翻译、凭证、模型清单全在**适配器**里 —— core 不含任何上游知识,
|
|
6
|
+
* 所以两家适配器共用这一个服务实现,改一处两家都受益。
|
|
7
|
+
*
|
|
8
|
+
* 暴露的端点(与 OpenAI / Anthropic 客户端约定一致):
|
|
9
|
+
* POST /v1/chat/completions OpenAI 兼容聊天(Cursor / Cherry Studio 等)
|
|
10
|
+
* POST /v1/messages Anthropic 兼容聊天(Claude Code 等)
|
|
11
|
+
* GET /v1/models 模型列表(OpenAI 形状)
|
|
12
|
+
* GET /healthz 健康检查(无鉴权,供探活)
|
|
13
|
+
*
|
|
14
|
+
* 安全默认:只监听回环地址;`--host 0.0.0.0` 需显式指定并打印警告。
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'node:http';
|
|
17
|
+
import { aggregateSseToJson } from './aggregate.js';
|
|
18
|
+
/** 从入站头里取密钥(OpenAI 用 Bearer,Anthropic 用 x-api-key)。 */
|
|
19
|
+
function extractKey(req) {
|
|
20
|
+
const auth = req.headers['authorization'];
|
|
21
|
+
if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
|
|
22
|
+
return auth.slice(7).trim();
|
|
23
|
+
}
|
|
24
|
+
const apiKey = req.headers['x-api-key'];
|
|
25
|
+
if (typeof apiKey === 'string' && apiKey.trim())
|
|
26
|
+
return apiKey.trim();
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
async function readJsonBody(req, limitBytes = 8 * 1024 * 1024) {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
for await (const chunk of req) {
|
|
33
|
+
const buf = chunk;
|
|
34
|
+
size += buf.length;
|
|
35
|
+
if (size > limitBytes)
|
|
36
|
+
throw new Error('请求体过大');
|
|
37
|
+
chunks.push(buf);
|
|
38
|
+
}
|
|
39
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
40
|
+
return text ? JSON.parse(text) : {};
|
|
41
|
+
}
|
|
42
|
+
function sendJson(res, status, body) {
|
|
43
|
+
const text = JSON.stringify(body);
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
|
|
45
|
+
res.end(text);
|
|
46
|
+
}
|
|
47
|
+
/** OpenAI 形状的错误体(客户端普遍认这个)。 */
|
|
48
|
+
function sendError(res, status, message, code = 'upstream_error') {
|
|
49
|
+
sendJson(res, status, { error: { message, type: 'waygate_error', code } });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 启动服务。适配器提供协议侧的一切(凭证、翻译、流)。
|
|
53
|
+
*/
|
|
54
|
+
export function startServer(adapter, opts) {
|
|
55
|
+
const log = opts.log ?? ((line) => process.stdout.write(line + '\n'));
|
|
56
|
+
const server = createServer((req, res) => {
|
|
57
|
+
void handle(req, res).catch((err) => {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
log(`[错误] ${message}`);
|
|
60
|
+
if (!res.headersSent)
|
|
61
|
+
sendError(res, 502, message);
|
|
62
|
+
else
|
|
63
|
+
res.end();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
async function handle(req, res) {
|
|
67
|
+
const url = req.url ?? '/';
|
|
68
|
+
const path = url.split('?')[0] ?? '/';
|
|
69
|
+
/* 健康检查:无鉴权(探活用)。 */
|
|
70
|
+
if (path === '/healthz') {
|
|
71
|
+
sendJson(res, 200, { ok: true, service: adapter.command, upstream: adapter.upstreamLabel });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
/* 鉴权:其余端点都要密钥。 */
|
|
75
|
+
const provided = extractKey(req);
|
|
76
|
+
if (provided !== opts.apiKey) {
|
|
77
|
+
sendError(res, 401, '入站密钥缺失或不正确(Authorization: Bearer <key> 或 x-api-key)', 'invalid_api_key');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (path === '/v1/models' && req.method === 'GET') {
|
|
81
|
+
const models = await adapter.listModels();
|
|
82
|
+
sendJson(res, 200, {
|
|
83
|
+
object: 'list',
|
|
84
|
+
data: models.map((m) => ({ id: m.id, object: 'model', owned_by: adapter.command, created: 0 })),
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if ((path === '/v1/chat/completions' || path === '/v1/messages') && req.method === 'POST') {
|
|
89
|
+
const raw = (await readJsonBody(req));
|
|
90
|
+
if (!raw || typeof raw.model !== 'string' || !raw.model.trim()) {
|
|
91
|
+
sendError(res, 400, 'model 为必填项', 'invalid_request_error');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
|
|
95
|
+
sendError(res, 400, 'messages 为必填项且不能为空', 'invalid_request_error');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const body = { ...raw, model: raw.model, messages: raw.messages };
|
|
99
|
+
const wantsStream = body.stream !== false; // OpenAI 惯例:缺省 = 流式
|
|
100
|
+
if (opts.verbose)
|
|
101
|
+
log(`[请求] ${adapter.command} model=${body.model} messages=${body.messages.length} 流=${wantsStream}`);
|
|
102
|
+
/* 客户端断连即中止上游(不留下孤儿请求)。 */
|
|
103
|
+
const ac = new AbortController();
|
|
104
|
+
req.on('close', () => ac.abort());
|
|
105
|
+
const result = await adapter.chat(body, ac.signal);
|
|
106
|
+
/* stream:false → 聚合为单个 JSON 返回(OpenAI 规范要求);
|
|
107
|
+
stream:true(或缺省)→ SSE 原样透传。 */
|
|
108
|
+
if (!wantsStream) {
|
|
109
|
+
try {
|
|
110
|
+
const json = await aggregateSseToJson(result.stream);
|
|
111
|
+
sendJson(res, result.status, JSON.parse(json));
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (!res.headersSent)
|
|
115
|
+
sendError(res, 502, `非流式聚合失败:${err.message}`);
|
|
116
|
+
else
|
|
117
|
+
res.end();
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(result.status, {
|
|
122
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
123
|
+
'cache-control': 'no-cache',
|
|
124
|
+
connection: 'keep-alive',
|
|
125
|
+
'x-accel-buffering': 'no',
|
|
126
|
+
});
|
|
127
|
+
const reader = result.stream.getReader();
|
|
128
|
+
try {
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done)
|
|
132
|
+
break;
|
|
133
|
+
if (value)
|
|
134
|
+
res.write(value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
res.end();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sendError(res, 404, `未知端点 ${path}`, 'not_found');
|
|
144
|
+
}
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
server.once('error', reject);
|
|
147
|
+
server.listen(opts.port, opts.host, () => {
|
|
148
|
+
const addr = server.address();
|
|
149
|
+
const port = typeof addr === 'object' && addr ? addr.port : opts.port;
|
|
150
|
+
resolve({
|
|
151
|
+
port,
|
|
152
|
+
close: () => new Promise((done) => {
|
|
153
|
+
server.close(() => done());
|
|
154
|
+
/* 已建的 keep-alive 连接不主动断,close 会等它们;给一个兜底超时。 */
|
|
155
|
+
setTimeout(() => done(), 3000).unref?.();
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享类型(Waygate 家族)。
|
|
3
|
+
*
|
|
4
|
+
* 只放**跨仓库共用**的形状:聊天消息、上游凭证、模型条目、适配器契约。
|
|
5
|
+
* 各家的私有形状(COSY 会话、MiniMax refresh 响应等)留在各自仓库。
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* 入站聊天消息 —— **保持 OpenAI 原始字段名**(snake_case)。
|
|
9
|
+
*
|
|
10
|
+
* 为什么不归一成 camelCase:两家适配器都要读客户端发来的原始形状
|
|
11
|
+
* (`tool_call_id` / `tool_calls` 等),归一反而要求适配器再做一层反查;
|
|
12
|
+
* 且客户端字段可能随版本增减,保留原始形状 + 宽松的可选字段最不容易漏。
|
|
13
|
+
*/
|
|
14
|
+
export interface ChatMessage {
|
|
15
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
16
|
+
content: unknown;
|
|
17
|
+
/** 工具结果消息关联的调用 id(OpenAI 原始字段名)。 */
|
|
18
|
+
tool_call_id?: string;
|
|
19
|
+
/** assistant 消息里的工具调用(OpenAI 原始形状)。 */
|
|
20
|
+
tool_calls?: Array<{
|
|
21
|
+
id?: string;
|
|
22
|
+
type?: string;
|
|
23
|
+
function?: {
|
|
24
|
+
name?: string;
|
|
25
|
+
arguments?: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
/** 入站请求体(两家共用的最小字段集;额外字段由各家适配器自行读取)。 */
|
|
31
|
+
export interface ChatRequestBody {
|
|
32
|
+
model: string;
|
|
33
|
+
messages: ChatMessage[];
|
|
34
|
+
stream?: boolean;
|
|
35
|
+
temperature?: number;
|
|
36
|
+
max_tokens?: number;
|
|
37
|
+
top_p?: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
/** 模型条目:`id` 是路由键(客户端填的模型名),`displayName` 给人看。 */
|
|
41
|
+
export interface ModelEntry {
|
|
42
|
+
id: string;
|
|
43
|
+
displayName?: string;
|
|
44
|
+
/** 上游标记(如计价徽标);无则 null。 */
|
|
45
|
+
tag?: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** 凭证就绪状态(doctor / 启动输出共用)。 */
|
|
48
|
+
export interface CredentialStatus {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
/** 人话描述(如「专用账号 A,access 有效至 2026-11-10」)。 */
|
|
51
|
+
detail: string;
|
|
52
|
+
/** 凭证来源文件路径(给用户排查用;不含令牌本体)。 */
|
|
53
|
+
source?: string;
|
|
54
|
+
}
|
|
55
|
+
/** 一次上游调用的结果:SSE 字节流(两家最终都归一到 OpenAI SSE 形状)。 */
|
|
56
|
+
export interface UpstreamCallResult {
|
|
57
|
+
/** 响应体流(已是 OpenAI 兼容 SSE 形状,或原始裸流由 core 负责写出)。 */
|
|
58
|
+
stream: ReadableStream<Uint8Array>;
|
|
59
|
+
status: number;
|
|
60
|
+
}
|
|
61
|
+
/** 适配器契约:每家实现这一组钩子,core 负责 HTTP 服务与生命周期。 */
|
|
62
|
+
export interface Adapter {
|
|
63
|
+
/** 命令名(如 'qoc' / 'mmcode'),用于错误信息与配置目录。 */
|
|
64
|
+
readonly command: string;
|
|
65
|
+
/** 展示名(如 'Qoder CN' / 'MiniMax Code')。 */
|
|
66
|
+
readonly displayName: string;
|
|
67
|
+
/** 默认端口。 */
|
|
68
|
+
readonly defaultPort: number;
|
|
69
|
+
/** 人话的上游描述(启动输出用)。 */
|
|
70
|
+
readonly upstreamLabel: string;
|
|
71
|
+
/** 凭证是否就绪 + 人话状态。 */
|
|
72
|
+
credentialStatus(): Promise<CredentialStatus>;
|
|
73
|
+
/** 引导导入/刷新本机客户端登录态(`login` 子命令)。 */
|
|
74
|
+
login(): Promise<void>;
|
|
75
|
+
/** 列出模型(`models` 子命令 + 启动输出)。 */
|
|
76
|
+
listModels(): Promise<ModelEntry[]>;
|
|
77
|
+
/** 处理一次聊天请求,返回 OpenAI 兼容 SSE 流。 */
|
|
78
|
+
chat(body: ChatRequestBody, signal: AbortSignal): Promise<UpstreamCallResult>;
|
|
79
|
+
/** 可选:额外的自检项(如上游连通性)。 */
|
|
80
|
+
doctorChecks?(): Promise<Array<{
|
|
81
|
+
name: string;
|
|
82
|
+
ok: boolean;
|
|
83
|
+
detail: string;
|
|
84
|
+
}>>;
|
|
85
|
+
/** 可选:本家特有的子命令(如 MiniMax 的签到/积分)。core 负责解析与分发。 */
|
|
86
|
+
extraCommands?: ExtraCommand[];
|
|
87
|
+
}
|
|
88
|
+
/** 适配器自有的子命令(在 core 的通用子命令之外)。 */
|
|
89
|
+
export interface ExtraCommand {
|
|
90
|
+
/** 子命令名(如 'checkin' / 'credits')。 */
|
|
91
|
+
name: string;
|
|
92
|
+
/** 一句话说明(帮助与列表用)。 */
|
|
93
|
+
summary: string;
|
|
94
|
+
/** 用法行(帮助里显示的完整形态)。 */
|
|
95
|
+
usage: string;
|
|
96
|
+
/** 执行;`json` 为真时输出机器可读结果(返回的字符串直接打印)。 */
|
|
97
|
+
run(opts: {
|
|
98
|
+
json: boolean;
|
|
99
|
+
args: string[];
|
|
100
|
+
}): Promise<string>;
|
|
101
|
+
}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waygate-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Waygate 家族共享骨架:本地反代 HTTP 服务、入站密钥、CLI、自检",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
27
|
+
"prepare": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.0",
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^2.1.9"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|