llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/cli-anthropic.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 模式二 · Anthropic 兼容接入
|
|
4
|
+
*
|
|
5
|
+
* 通过官方 `@anthropic-ai/sdk` 直连网关的 /v1/messages,验证网关可以作为
|
|
6
|
+
* api.anthropic.com 的 drop-in 替代 —— 客户端只需把 baseURL 指到网关、
|
|
7
|
+
* apiKey 换成网关发放的 sk- 密钥,其余代码完全不变。
|
|
8
|
+
*
|
|
9
|
+
* 用法示例:
|
|
10
|
+
* node cli-anthropic.js "你好,用一句话介绍你自己"
|
|
11
|
+
* node cli-anthropic.js -p "你好" --model qwen3:8b --system "你是中文助手"
|
|
12
|
+
* echo "你好" | node cli-anthropic.js
|
|
13
|
+
* node cli-anthropic.js -i # 多轮交互
|
|
14
|
+
*
|
|
15
|
+
* 另外:本脚本会额外发一个 W3C `baggage: session.id=<uuid>`(一次运行 = 一次对话),
|
|
16
|
+
* 好让网关后台的「任务树」把同一会话的多次请求聚成一条。这个头是**可选**的 ——
|
|
17
|
+
* 不发(或任何客户端不发)功能完全不受影响,网关会退回自己的推断口径。
|
|
18
|
+
*
|
|
19
|
+
* 密钥来源(优先级从高到低):--key 参数 > 环境变量 SK / GATEWAY_KEY / ANTHROPIC_API_KEY > .env 文件
|
|
20
|
+
* 配置解析统一在 lib/config.js —— 四个入口共用一套优先级,不再各写一份。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import readline from 'node:readline';
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
import { loadDotEnv, parseArgs, sessionBaggage, DEFAULT_BASE_URL } from './lib/common.js';
|
|
30
|
+
import { resolveConfig, fail, KEY_HINT } from './lib/config.js';
|
|
31
|
+
|
|
32
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参与默认值计算
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
36
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
37
|
+
|
|
38
|
+
const HELP = `用法:node cli-anthropic.js [选项] [提示词]
|
|
39
|
+
|
|
40
|
+
选项:
|
|
41
|
+
-p, --prompt <文本> 单轮提问内容(省略则读位置参数 / 标准输入)
|
|
42
|
+
-s, --system <文本> system 提示词
|
|
43
|
+
-m, --model <模型> 模型名(默认 ${DEFAULT_MODEL})
|
|
44
|
+
-k, --key <sk-密钥> 网关发放的密钥(也可用环境变量 SK/GATEWAY_KEY/ANTHROPIC_API_KEY 或 .env)
|
|
45
|
+
--base-url <地址> 网关地址(默认 ${DEFAULT_BASE_URL})
|
|
46
|
+
--max-tokens <值> 最大生成 token 数(Anthropic 必填,默认 ${DEFAULT_MAX_TOKENS})
|
|
47
|
+
--temperature <值> 采样温度
|
|
48
|
+
--no-stream 关闭流式输出(默认开启)
|
|
49
|
+
--json 输出完整原始响应(非流式)
|
|
50
|
+
-i, --interactive 进入多轮交互(REPL)
|
|
51
|
+
-h, --help 显示帮助
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
// 值型选项:会消费下一个参数作为值;布尔型选项:不消费后续参数
|
|
55
|
+
const VALUE_FLAGS = {
|
|
56
|
+
'--prompt': 'prompt', '-p': 'prompt',
|
|
57
|
+
'--system': 'system', '-s': 'system',
|
|
58
|
+
'--model': 'model', '-m': 'model',
|
|
59
|
+
'--key': 'key', '-k': 'key',
|
|
60
|
+
'--base-url': 'base-url',
|
|
61
|
+
'--temperature': 'temperature',
|
|
62
|
+
'--max-tokens': 'max-tokens',
|
|
63
|
+
};
|
|
64
|
+
const BOOL_FLAGS = {
|
|
65
|
+
'--no-stream': 'no-stream',
|
|
66
|
+
'--json': 'json',
|
|
67
|
+
'--interactive': 'interactive', '-i': 'interactive',
|
|
68
|
+
'--help': 'help', '-h': 'help',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function readStdin() {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
if (process.stdin.isTTY) return resolve('');
|
|
74
|
+
let data = '';
|
|
75
|
+
process.stdin.setEncoding('utf8');
|
|
76
|
+
process.stdin.on('data', (c) => (data += c));
|
|
77
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
78
|
+
process.stdin.on('error', () => resolve(''));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- 主流程 ----------
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
const args = parseArgs(process.argv.slice(2), VALUE_FLAGS, BOOL_FLAGS);
|
|
86
|
+
|
|
87
|
+
if (args.help) {
|
|
88
|
+
console.log(HELP);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { key, baseUrl, model, temperature, maxTokens } = resolveConfig(args, {
|
|
93
|
+
defaultModel: DEFAULT_MODEL,
|
|
94
|
+
defaultMaxTokens: DEFAULT_MAX_TOKENS,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!key) {
|
|
98
|
+
console.error(`[错误] ${KEY_HINT}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 一次进程运行 = 一次对话。用 W3C baggage 告诉网关,好让它把同一会话的请求聚成一条。
|
|
103
|
+
const sessionId = randomUUID();
|
|
104
|
+
const baggage = sessionBaggage(sessionId);
|
|
105
|
+
if (baggage) process.stderr.write(`[gateway] 会话 ${sessionId}(W3C baggage: session.id)\n`);
|
|
106
|
+
|
|
107
|
+
const client = new Anthropic({
|
|
108
|
+
baseURL: baseUrl,
|
|
109
|
+
apiKey: key,
|
|
110
|
+
...(baggage ? { defaultHeaders: { baggage } } : {}),
|
|
111
|
+
});
|
|
112
|
+
const stream = !args['no-stream'];
|
|
113
|
+
|
|
114
|
+
if (args.interactive) {
|
|
115
|
+
await repl(client, model, { stream, maxTokens, temperature });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let prompt = args.prompt ?? '';
|
|
120
|
+
if (!prompt && args._.length) prompt = args._.join(' ');
|
|
121
|
+
if (!prompt) prompt = await readStdin();
|
|
122
|
+
if (!prompt) {
|
|
123
|
+
console.log(HELP);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await chat(client, model, prompt, {
|
|
128
|
+
system: args.system,
|
|
129
|
+
stream,
|
|
130
|
+
maxTokens,
|
|
131
|
+
temperature,
|
|
132
|
+
json: args.json,
|
|
133
|
+
baseUrl,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {boolean} exitOnError 单轮/管道模式为 true;交互模式为 false(一次失败不结束会话)
|
|
139
|
+
*/
|
|
140
|
+
async function chat(client, model, prompt, { system, stream, maxTokens, temperature, json, baseUrl, exitOnError = true }) {
|
|
141
|
+
const req = {
|
|
142
|
+
model,
|
|
143
|
+
max_tokens: maxTokens,
|
|
144
|
+
messages: [{ role: 'user', content: prompt }],
|
|
145
|
+
stream,
|
|
146
|
+
temperature,
|
|
147
|
+
};
|
|
148
|
+
if (system) req.system = system;
|
|
149
|
+
|
|
150
|
+
process.stderr.write(`[gateway] model=${model} base=${baseUrl} stream=${stream}\n`);
|
|
151
|
+
try {
|
|
152
|
+
if (stream) {
|
|
153
|
+
let inputTokens = null;
|
|
154
|
+
let outputTokens = null;
|
|
155
|
+
for await (const event of await client.messages.create(req)) {
|
|
156
|
+
if (event.type === 'message_start') {
|
|
157
|
+
inputTokens = event.message?.usage?.input_tokens ?? null;
|
|
158
|
+
} else if (event.type === 'content_block_delta') {
|
|
159
|
+
const d = event.delta;
|
|
160
|
+
if (d?.type === 'text_delta') process.stdout.write(d.text);
|
|
161
|
+
else if (d?.type === 'input_json_delta') process.stdout.write(d.partial_json); // 工具调用参数
|
|
162
|
+
} else if (event.type === 'message_delta') {
|
|
163
|
+
outputTokens = event.usage?.output_tokens ?? null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
process.stdout.write('\n');
|
|
167
|
+
// 跨方言(Anthropic 入口 → OpenAI 上游)流式时网关不逐块回传 usage,仅在确有输出 token 时才展示
|
|
168
|
+
if (outputTokens !== null) {
|
|
169
|
+
process.stderr.write(`[usage] 输入 ${inputTokens ?? '?'} / 输出 ${outputTokens} tokens\n`);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
const resp = await client.messages.create(req);
|
|
173
|
+
if (json) {
|
|
174
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
175
|
+
} else {
|
|
176
|
+
const text = (resp.content || [])
|
|
177
|
+
.map((b) => (b.type === 'text' ? b.text : b.type === 'tool_use' ? `[工具调用 ${b.name}]` : ''))
|
|
178
|
+
.join('');
|
|
179
|
+
console.log(text);
|
|
180
|
+
}
|
|
181
|
+
const u = resp.usage;
|
|
182
|
+
if (u) process.stderr.write(`[usage] 输入 ${u.input_tokens ?? '?'} / 输出 ${u.output_tokens ?? '?'} tokens\n`);
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
fail(e, { exit: exitOnError });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function repl(client, model, opts) {
|
|
190
|
+
console.error(`[gateway] 交互模式 model=${model},输入文本提问,\`exit\` 退出`);
|
|
191
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
192
|
+
const messages = [];
|
|
193
|
+
const ask = () => new Promise((resolve) => rl.question('你> ', resolve));
|
|
194
|
+
for (;;) {
|
|
195
|
+
let line;
|
|
196
|
+
try {
|
|
197
|
+
line = (await ask()).trim();
|
|
198
|
+
} catch {
|
|
199
|
+
break; // 输入流关闭(Ctrl+D)
|
|
200
|
+
}
|
|
201
|
+
if (!line) continue;
|
|
202
|
+
if (/^(exit|quit)$/i.test(line)) break;
|
|
203
|
+
messages.push({ role: 'user', content: line });
|
|
204
|
+
|
|
205
|
+
// 复用底层流式,逐字打印并把助手回复累积进上下文
|
|
206
|
+
process.stdout.write('助手> ');
|
|
207
|
+
const req = { model, max_tokens: opts.maxTokens, messages, stream: true, temperature: opts.temperature };
|
|
208
|
+
try {
|
|
209
|
+
let out = '';
|
|
210
|
+
let inputTokens = null;
|
|
211
|
+
let outputTokens = null;
|
|
212
|
+
for await (const event of await client.messages.create(req)) {
|
|
213
|
+
if (event.type === 'message_start') inputTokens = event.message?.usage?.input_tokens ?? null;
|
|
214
|
+
else if (event.type === 'content_block_delta') {
|
|
215
|
+
const d = event.delta;
|
|
216
|
+
if (d?.type === 'text_delta') {
|
|
217
|
+
process.stdout.write(d.text);
|
|
218
|
+
out += d.text;
|
|
219
|
+
}
|
|
220
|
+
} else if (event.type === 'message_delta') outputTokens = event.usage?.output_tokens ?? null;
|
|
221
|
+
}
|
|
222
|
+
process.stdout.write('\n');
|
|
223
|
+
if (outputTokens !== null) {
|
|
224
|
+
process.stderr.write(`[usage] 输入 ${inputTokens ?? '?'} / 输出 ${outputTokens} tokens\n`);
|
|
225
|
+
}
|
|
226
|
+
messages.push({ role: 'assistant', content: out });
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// 交互模式:打印后继续,不要把整个会话带走
|
|
229
|
+
fail(e, { exit: false });
|
|
230
|
+
messages.pop();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
rl.close();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main();
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 模式三(推荐)· Claude Code CLI 接入
|
|
4
|
+
*
|
|
5
|
+
* 网关为每个 Agent 开发发放独立 sk- 密钥,Claude Code 通过
|
|
6
|
+
* ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN 直连网关即可把本地/DeepSeek 等
|
|
7
|
+
* 上游模型当后端使用。本脚本负责「设置环境变量 → 拉起 claude CLI」,
|
|
8
|
+
* 并把 `--` 之后的参数原样透传给 claude。
|
|
9
|
+
*
|
|
10
|
+
* 用法示例:
|
|
11
|
+
* node cli-claude-code.js # 进入 Claude Code 交互界面
|
|
12
|
+
* node cli-claude-code.js -- -p "用一句话介绍你自己" # 透传 claude 参数(非交互)
|
|
13
|
+
* node cli-claude-code.js --smoke # 只做一次连通性自检后退出
|
|
14
|
+
* node cli-claude-code.js --print-env # 只打印要注入的环境变量
|
|
15
|
+
* node cli-claude-code.js --config # 打印 CC Switch 可用的 JSON 配置
|
|
16
|
+
*
|
|
17
|
+
* 密钥来源(优先级从高到低):--key 参数 > 环境变量 SK / GATEWAY_KEY / ANTHROPIC_AUTH_TOKEN > .env 文件
|
|
18
|
+
* 密钥 / baseUrl / 模型的解析统一走 lib/config.js,与其余三个入口保持同一套优先级。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { loadDotEnv, DEFAULT_BASE_URL, sessionBaggage } from './lib/common.js';
|
|
27
|
+
import { resolveConfig, KEY_HINT } from './lib/config.js';
|
|
28
|
+
|
|
29
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
loadDotEnv(SCRIPT_DIR); // 先加载 .env,让 GATEWAY_MODEL 等环境变量参与默认值计算
|
|
31
|
+
|
|
32
|
+
const DEFAULT_MODEL = process.env.GATEWAY_MODEL || 'qwen3:8b';
|
|
33
|
+
const DEFAULT_HAIKU = 'qwen3:8b-nothink';
|
|
34
|
+
|
|
35
|
+
const HELP = `用法:node cli-claude-code.js [选项] [-- claude 参数...]
|
|
36
|
+
|
|
37
|
+
选项(本脚本自身):
|
|
38
|
+
-k, --key <sk-密钥> 网关发放的密钥(可用环境变量 SK/GATEWAY_KEY/ANTHROPIC_AUTH_TOKEN 或 .env)
|
|
39
|
+
--base-url <地址> 网关地址(默认 ${DEFAULT_BASE_URL})
|
|
40
|
+
-m, --model <模型> ANTHROPIC_MODEL,主模型(默认 ${DEFAULT_MODEL})
|
|
41
|
+
--opus-model <模型> ANTHROPIC_DEFAULT_OPUS_MODEL(默认同 --model)
|
|
42
|
+
--sonnet-model <模型> ANTHROPIC_DEFAULT_SONNET_MODEL(默认同 --model)
|
|
43
|
+
--haiku-model <模型> ANTHROPIC_DEFAULT_HAIKU_MODEL(默认 ${DEFAULT_HAIKU})
|
|
44
|
+
--small-fast-model <模型> ANTHROPIC_SMALL_FAST_MODEL,后台快速模型(默认同 haiku)
|
|
45
|
+
--smoke 仅做一次 /v1/messages 连通性自检后退出,不拉起 claude
|
|
46
|
+
--print-env 仅打印要注入的环境变量,不拉起 claude
|
|
47
|
+
--config 打印 CC Switch / CC GUI 可直接粘贴的 JSON 配置
|
|
48
|
+
-h, --help 显示帮助
|
|
49
|
+
|
|
50
|
+
透传:\`--\` 之后的参数原样交给 claude,例如:
|
|
51
|
+
node cli-claude-code.js -- -p "你好" --model deepseek-v4-pro
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
// ---------- 参数解析:本脚本自己的参数 vs 透传给 claude 的参数 ----------
|
|
55
|
+
// 注意这里不能直接用 lib/common.js 的 parseArgs:本脚本要把「不认识的参数」
|
|
56
|
+
// 原样透传给 claude,而通用解析器会把它们吃掉。
|
|
57
|
+
|
|
58
|
+
const WITH_VALUE = {
|
|
59
|
+
'--key': 'key',
|
|
60
|
+
'--base-url': 'baseUrl',
|
|
61
|
+
'--model': 'model',
|
|
62
|
+
'--opus-model': 'opusModel',
|
|
63
|
+
'--sonnet-model': 'sonnetModel',
|
|
64
|
+
'--haiku-model': 'haikuModel',
|
|
65
|
+
'--small-fast-model': 'smallFastModel',
|
|
66
|
+
'-k': 'key',
|
|
67
|
+
'-m': 'model',
|
|
68
|
+
};
|
|
69
|
+
const FLAG = {
|
|
70
|
+
'--smoke': 'smoke',
|
|
71
|
+
'--print-env': 'printEnv',
|
|
72
|
+
'--config': 'config',
|
|
73
|
+
'--help': 'help',
|
|
74
|
+
'-h': 'help',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function parseArgs(argv) {
|
|
78
|
+
const opts = {};
|
|
79
|
+
const passthrough = [];
|
|
80
|
+
let afterDoubleDash = false;
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const a = argv[i];
|
|
83
|
+
if (afterDoubleDash) {
|
|
84
|
+
passthrough.push(a);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (a === '--') {
|
|
88
|
+
afterDoubleDash = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// --flag=value 形式(仅识别本脚本自己的参数)
|
|
92
|
+
const eq = a.indexOf('=');
|
|
93
|
+
if (eq !== -1 && a.startsWith('--')) {
|
|
94
|
+
const name = a.slice(0, eq);
|
|
95
|
+
const value = a.slice(eq + 1);
|
|
96
|
+
if (name in WITH_VALUE) {
|
|
97
|
+
opts[WITH_VALUE[name]] = value;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (a in WITH_VALUE) {
|
|
102
|
+
const next = argv[i + 1];
|
|
103
|
+
opts[WITH_VALUE[a]] = next !== undefined ? next : true;
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (a in FLAG) {
|
|
108
|
+
opts[FLAG[a]] = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// 其余参数原样透传给 claude(含其值,交由 claude 自行解析)
|
|
112
|
+
passthrough.push(a);
|
|
113
|
+
}
|
|
114
|
+
return { opts, passthrough };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- 构建要注入的环境变量 ----------
|
|
118
|
+
|
|
119
|
+
function buildEnv(opts, key, baseUrl, model) {
|
|
120
|
+
const haiku = opts.haikuModel || DEFAULT_HAIKU;
|
|
121
|
+
const env = {
|
|
122
|
+
...process.env,
|
|
123
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
124
|
+
ANTHROPIC_AUTH_TOKEN: key,
|
|
125
|
+
ANTHROPIC_MODEL: model,
|
|
126
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: opts.opusModel || model,
|
|
127
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: opts.sonnetModel || model,
|
|
128
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku,
|
|
129
|
+
ANTHROPIC_SMALL_FAST_MODEL: opts.smallFastModel || haiku,
|
|
130
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', // 关闭遥测/模型探测等非必要流量,避免网关收到陌生端点
|
|
131
|
+
};
|
|
132
|
+
// 任务树标记:告诉网关「这些调用属于同一趟会话」,否则网关只能靠指纹去猜。
|
|
133
|
+
// 真正的 HTTP 请求是 claude 自己发的,本脚本碰不到它的头,只能借 Claude Code
|
|
134
|
+
// 支持的自定义头环境变量注入(格式:每行一个 `名字: 值`,多行用 \n 分隔)。
|
|
135
|
+
// 一次进程启动 = 一趟会话:id 每次随机,所以两次启动是两个任务,不会互相污染。
|
|
136
|
+
// 想跨启动续同一趟会话,就自己设好 ANTHROPIC_CUSTOM_HEADERS(见下)。
|
|
137
|
+
const baggage = sessionBaggage(randomUUID());
|
|
138
|
+
if (baggage) {
|
|
139
|
+
const line = `baggage: ${baggage}`;
|
|
140
|
+
const cur = String(env.ANTHROPIC_CUSTOM_HEADERS || '').trim();
|
|
141
|
+
if (!cur) {
|
|
142
|
+
env.ANTHROPIC_CUSTOM_HEADERS = line;
|
|
143
|
+
} else if (!/baggage/i.test(cur)) {
|
|
144
|
+
env.ANTHROPIC_CUSTOM_HEADERS = `${cur}\n${line}`; // 追加,不覆盖用户其它自定义头
|
|
145
|
+
}
|
|
146
|
+
// 用户自己设过 baggage 就原样尊重:两条 baggage 会让接收方解析出怪东西
|
|
147
|
+
}
|
|
148
|
+
return env;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------- 连通性自检:直接 POST /v1/messages ----------
|
|
152
|
+
|
|
153
|
+
async function smoke(baseUrl, key, model) {
|
|
154
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/v1/messages`;
|
|
155
|
+
// 用流式请求自检:Claude Code 实际走的就是 /v1/messages 的 SSE 流,最能反映真实可用性
|
|
156
|
+
// max_tokens 给足:本地 thinking 模型(如 qwen3:8b)会先把额度花在 reasoning 上,太小则看不到正文
|
|
157
|
+
const body = { model, max_tokens: 256, messages: [{ role: 'user', content: '你好,请用一句话回复' }], stream: true };
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
res = await fetch(url, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
'content-type': 'application/json',
|
|
164
|
+
'x-api-key': key,
|
|
165
|
+
'anthropic-version': '2023-06-01',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error(`[smoke] 连接异常: ${e?.message || e}`);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const text = await res.text();
|
|
175
|
+
console.error(`[smoke] 失败 HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 解析 SSE:收到 message_start 即证明网关已正确翻译并开始流式返回
|
|
180
|
+
const reader = res.body.getReader();
|
|
181
|
+
const decoder = new TextDecoder();
|
|
182
|
+
let buf = '';
|
|
183
|
+
let started = false;
|
|
184
|
+
let reply = '';
|
|
185
|
+
for (;;) {
|
|
186
|
+
const { done, value } = await reader.read();
|
|
187
|
+
if (done) break;
|
|
188
|
+
buf += decoder.decode(value, { stream: true });
|
|
189
|
+
const parts = buf.split('\n\n');
|
|
190
|
+
buf = parts.pop();
|
|
191
|
+
for (const seg of parts) {
|
|
192
|
+
let eventType = null;
|
|
193
|
+
let data = null;
|
|
194
|
+
for (const line of seg.split('\n')) {
|
|
195
|
+
if (line.startsWith('event:')) eventType = line.slice(6).trim();
|
|
196
|
+
else if (line.startsWith('data:')) data = line.slice(5).trim();
|
|
197
|
+
}
|
|
198
|
+
if (!data) continue;
|
|
199
|
+
let obj = null;
|
|
200
|
+
try {
|
|
201
|
+
obj = JSON.parse(data);
|
|
202
|
+
} catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (eventType === 'message_start' || obj.type === 'message_start') {
|
|
206
|
+
started = true;
|
|
207
|
+
} else if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta') {
|
|
208
|
+
reply += obj.delta.text;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!started) {
|
|
213
|
+
console.error('[smoke] 失败:未收到 message_start 事件');
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
console.error(`[smoke] 成功 model=${model} 回复="${reply.slice(0, 40)}"`);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------- 拉起 claude ----------
|
|
221
|
+
|
|
222
|
+
function runClaude(passthrough, env) {
|
|
223
|
+
// 先按裸可执行名直接 spawn(原生二进制 / 已加入 PATH 的情况)
|
|
224
|
+
const r = spawnSync('claude', passthrough, { env, stdio: 'inherit' });
|
|
225
|
+
if (!r.error) return r.status ?? 0;
|
|
226
|
+
// Windows 下 npm 安装的 claude 是 .cmd shim,直接 spawn 会报 ENOENT/EINVAL → 回退 shell 解析
|
|
227
|
+
if (r.error.code !== 'ENOENT' && r.error.code !== 'EINVAL') {
|
|
228
|
+
console.error(`[错误] 启动 claude 失败: ${r.error.message}`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
const cmd = ['claude', ...passthrough].map((s) => (/[\s"&|<>]/.test(s) ? JSON.stringify(s) : s)).join(' ');
|
|
232
|
+
const r2 = spawnSync(cmd, { env, stdio: 'inherit', shell: true });
|
|
233
|
+
if (r2.error) {
|
|
234
|
+
console.error('[错误] 未找到 claude 命令:请先安装 Claude Code(npm install -g @anthropic-ai/claude-code)');
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
return r2.status ?? 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------- 主流程 ----------
|
|
241
|
+
|
|
242
|
+
async function main() {
|
|
243
|
+
const { opts, passthrough } = parseArgs(process.argv.slice(2));
|
|
244
|
+
|
|
245
|
+
if (opts.help) {
|
|
246
|
+
console.log(HELP);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 本脚本的键名(baseUrl)与通用约定(base-url)不同,这里贴一下再交给 resolveConfig
|
|
251
|
+
const { key, baseUrl, model } = resolveConfig(
|
|
252
|
+
{ key: opts.key, 'base-url': opts.baseUrl, model: opts.model },
|
|
253
|
+
{ defaultModel: DEFAULT_MODEL },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
if (!key) {
|
|
257
|
+
console.error(`[错误] ${KEY_HINT}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const env = buildEnv(opts, key, baseUrl, model);
|
|
262
|
+
|
|
263
|
+
if (opts.smoke) {
|
|
264
|
+
const ok = await smoke(baseUrl, key, model);
|
|
265
|
+
process.exit(ok ? 0 : 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (opts.printEnv) {
|
|
269
|
+
const keys = [
|
|
270
|
+
'ANTHROPIC_BASE_URL',
|
|
271
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
272
|
+
'ANTHROPIC_MODEL',
|
|
273
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
274
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
275
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
276
|
+
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
277
|
+
'ANTHROPIC_CUSTOM_HEADERS', // 任务树标记(W3C baggage: session.id)就走这个头
|
|
278
|
+
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
279
|
+
];
|
|
280
|
+
for (const k of keys) console.log(`${k}=${env[k]}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (opts.config) {
|
|
285
|
+
// 注意这里**不写** ANTHROPIC_CUSTOM_HEADERS:它是给「一次进程启动 = 一趟会话」用的
|
|
286
|
+
// 随机值,写进静态配置文件会让所有会话共用一个 id,反而把任务树并成一棵。
|
|
287
|
+
// 想用配置文件接入,请自己在那里写一行固定值(或改用本脚本启动)。
|
|
288
|
+
console.log(
|
|
289
|
+
JSON.stringify(
|
|
290
|
+
{
|
|
291
|
+
env: {
|
|
292
|
+
ANTHROPIC_AUTH_TOKEN: key,
|
|
293
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
294
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
295
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
296
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
297
|
+
ANTHROPIC_MODEL: env.ANTHROPIC_MODEL,
|
|
298
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
299
|
+
},
|
|
300
|
+
theme: 'dark',
|
|
301
|
+
},
|
|
302
|
+
null,
|
|
303
|
+
2,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
process.stderr.write(`[gateway] 启动 Claude Code:base=${baseUrl} model=${env.ANTHROPIC_MODEL}\n`);
|
|
310
|
+
// 把会话 id 显式打出来(与另外三个入口的提示一致),否则任务树里对不上是哪一次启动
|
|
311
|
+
const sid = /session\.id=([A-Za-z0-9._-]+)/.exec(String(env.ANTHROPIC_CUSTOM_HEADERS || ''));
|
|
312
|
+
if (sid) process.stderr.write(`[gateway] 会话 ${sid[1]}(W3C baggage: session.id)\n`);
|
|
313
|
+
const code = runClaude(passthrough, env);
|
|
314
|
+
process.exit(code);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
main();
|