@nvae/llmswitch 0.6.0 → 0.8.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 +220 -0
- package/dist/adapters/opencode.js +28 -12
- package/dist/bridge/anthropic-to-chat-response.js +332 -0
- package/dist/bridge/chat-to-anthropic-request.js +270 -0
- package/dist/bridge/chat-to-responses-request.js +216 -0
- package/dist/bridge/manager.js +45 -13
- package/dist/bridge/responses-to-chat-response.js +393 -0
- package/dist/bridge/server.js +109 -4
- package/dist/bridge/state.js +8 -2
- package/dist/bridge/types.js +4 -2
- package/dist/cli.js +2 -0
- package/dist/commands/bridge-cmd.js +3 -2
- package/dist/commands/gateway-cmd.js +1040 -0
- package/dist/gateway/health.js +45 -0
- package/dist/gateway/keys.js +433 -0
- package/dist/gateway/manager.js +278 -0
- package/dist/gateway/pipeline.js +328 -0
- package/dist/gateway/rate-limit.js +285 -0
- package/dist/gateway/router.js +163 -0
- package/dist/gateway/runtime.js +45 -0
- package/dist/gateway/server.js +1053 -0
- package/dist/gateway/state.js +135 -0
- package/dist/gateway/store.js +392 -0
- package/dist/gateway/tokens.js +423 -0
- package/dist/gateway/types.js +30 -0
- package/dist/gateway/usage.js +152 -0
- package/dist/utils/paths.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway daemon lifecycle: probe, start, stop, foreground run.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the bridge manager's cooperative-shutdown model — the daemon is only
|
|
5
|
+
* ever asked to exit through an authenticated control call, never signalled by
|
|
6
|
+
* PID — but uses its own port, state file and log file.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { existsSync, openSync, renameSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { ensureDir } from "../utils/fs.js";
|
|
14
|
+
import { getGatewayDir } from "../utils/paths.js";
|
|
15
|
+
import { formatHostForUrl } from "../bridge/runtime.js";
|
|
16
|
+
import { generateGatewayControlToken, gatewayRootUrl, readGatewayState, updateGatewayState, } from "./state.js";
|
|
17
|
+
import { resolveGatewayListener } from "./runtime.js";
|
|
18
|
+
import { listenGateway } from "./server.js";
|
|
19
|
+
import { DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, } from "./types.js";
|
|
20
|
+
export class GatewayPortOccupiedError extends Error {
|
|
21
|
+
constructor(host, port) {
|
|
22
|
+
super(`端口 ${host}:${port} 已被其他进程占用;为避免误杀,llm-switch 不会自动终止该进程。`);
|
|
23
|
+
this.name = "GatewayPortOccupiedError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class GatewayControlError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "GatewayControlError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function getGatewayLogPath() {
|
|
33
|
+
return join(getGatewayDir(), "gateway.log");
|
|
34
|
+
}
|
|
35
|
+
const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
|
|
36
|
+
/** Size-based rotation at process start: gateway.log → gateway.log.old. */
|
|
37
|
+
export function rotateGatewayLogIfNeeded() {
|
|
38
|
+
const path = getGatewayLogPath();
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(path).size < LOG_ROTATE_BYTES)
|
|
41
|
+
return;
|
|
42
|
+
rmSync(`${path}.old`, { force: true });
|
|
43
|
+
renameSync(path, `${path}.old`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// No log file yet, or rotation raced with another process; keep going.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function controlUrl(host, port, path) {
|
|
50
|
+
return `http://${formatHostForUrl(host)}:${port}${path}`;
|
|
51
|
+
}
|
|
52
|
+
export async function probeGateway(host = readGatewayState().listener.advertiseHost, port = readGatewayState().listener.port) {
|
|
53
|
+
const expected = readGatewayState().instance;
|
|
54
|
+
try {
|
|
55
|
+
const headers = {};
|
|
56
|
+
if (expected?.controlToken) {
|
|
57
|
+
headers["x-llm-switch-control"] = expected.controlToken;
|
|
58
|
+
}
|
|
59
|
+
const response = await fetch(controlUrl(host, port, "/health"), {
|
|
60
|
+
headers,
|
|
61
|
+
signal: AbortSignal.timeout(800),
|
|
62
|
+
});
|
|
63
|
+
let body = null;
|
|
64
|
+
try {
|
|
65
|
+
body = (await response.json());
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
body = null;
|
|
69
|
+
}
|
|
70
|
+
const instanceId = typeof body?.instanceId === "string" ? body.instanceId : undefined;
|
|
71
|
+
const breakers = Array.isArray(body?.breakers)
|
|
72
|
+
? body.breakers
|
|
73
|
+
: undefined;
|
|
74
|
+
const stats = body?.stats;
|
|
75
|
+
return {
|
|
76
|
+
reachable: true,
|
|
77
|
+
healthy: Boolean(response.ok && expected && instanceId && instanceId === expected.id),
|
|
78
|
+
instanceId,
|
|
79
|
+
...(typeof body?.startedAt === "string"
|
|
80
|
+
? { startedAt: body.startedAt }
|
|
81
|
+
: {}),
|
|
82
|
+
...(typeof body?.uptimeSeconds === "number"
|
|
83
|
+
? { uptimeSeconds: body.uptimeSeconds }
|
|
84
|
+
: {}),
|
|
85
|
+
...(stats && typeof stats === "object" ? { stats } : {}),
|
|
86
|
+
breakers,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { reachable: false, healthy: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function isGatewayAlive(host = readGatewayState().listener.advertiseHost, port = readGatewayState().listener.port) {
|
|
94
|
+
return (await probeGateway(host, port)).healthy;
|
|
95
|
+
}
|
|
96
|
+
export function readGatewayPid() {
|
|
97
|
+
return readGatewayState().instance?.pid ?? null;
|
|
98
|
+
}
|
|
99
|
+
export function isPidRunning(pid) {
|
|
100
|
+
if (!Number.isInteger(pid) || pid < 1 || pid > 2_147_483_647)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve how the detached daemon is launched. Node is preferred: Bun resolves
|
|
112
|
+
* target DNS locally, which defeats socks5h remote DNS for proxied upstreams.
|
|
113
|
+
*/
|
|
114
|
+
function resolveDaemonRunner() {
|
|
115
|
+
const candidates = [
|
|
116
|
+
fileURLToPath(new URL("../index.js", import.meta.url)),
|
|
117
|
+
fileURLToPath(new URL("../../dist/index.js", import.meta.url)),
|
|
118
|
+
fileURLToPath(new URL("../index.ts", import.meta.url)),
|
|
119
|
+
];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (existsSync(candidate)) {
|
|
122
|
+
return candidate.endsWith(".js")
|
|
123
|
+
? { command: "node", entry: candidate }
|
|
124
|
+
: { command: process.execPath, entry: candidate };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const running = process.argv[1];
|
|
128
|
+
if (running && existsSync(running)) {
|
|
129
|
+
return { command: process.execPath, entry: running };
|
|
130
|
+
}
|
|
131
|
+
throw new GatewayControlError("无法定位 llmswitch 入口文件");
|
|
132
|
+
}
|
|
133
|
+
export async function startGatewayDaemon(host = DEFAULT_GATEWAY_HOST, port = DEFAULT_GATEWAY_PORT, allowRemote = false) {
|
|
134
|
+
const listener = resolveGatewayListener({ host, port, allowRemote });
|
|
135
|
+
const probe = await probeGateway(listener.advertiseHost, listener.port);
|
|
136
|
+
if (probe.healthy)
|
|
137
|
+
return readGatewayPid() || 0;
|
|
138
|
+
if (probe.reachable) {
|
|
139
|
+
throw new GatewayPortOccupiedError(listener.bindHost, listener.port);
|
|
140
|
+
}
|
|
141
|
+
updateGatewayState((state) => ({ ...state, listener, instance: null }));
|
|
142
|
+
ensureDir(getGatewayDir());
|
|
143
|
+
rotateGatewayLogIfNeeded();
|
|
144
|
+
const logFd = openSync(getGatewayLogPath(), "a");
|
|
145
|
+
const runner = resolveDaemonRunner();
|
|
146
|
+
const args = [
|
|
147
|
+
runner.entry,
|
|
148
|
+
"gateway",
|
|
149
|
+
"serve",
|
|
150
|
+
"--host",
|
|
151
|
+
listener.bindHost,
|
|
152
|
+
"--port",
|
|
153
|
+
String(listener.port),
|
|
154
|
+
];
|
|
155
|
+
if (allowRemote)
|
|
156
|
+
args.push("--allow-remote");
|
|
157
|
+
const child = spawn(runner.command, args, {
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: ["ignore", logFd, logFd],
|
|
160
|
+
env: {
|
|
161
|
+
...process.env,
|
|
162
|
+
LLM_SWITCH_GATEWAY_HOST: listener.bindHost,
|
|
163
|
+
LLM_SWITCH_GATEWAY_PORT: String(listener.port),
|
|
164
|
+
LLM_SWITCH_GATEWAY_RUNTIME: runner.command,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
child.unref();
|
|
168
|
+
if (!child.pid)
|
|
169
|
+
throw new GatewayControlError("无法启动 gateway 进程");
|
|
170
|
+
return child.pid;
|
|
171
|
+
}
|
|
172
|
+
export async function stopGateway() {
|
|
173
|
+
const state = readGatewayState();
|
|
174
|
+
const instance = state.instance;
|
|
175
|
+
if (!instance)
|
|
176
|
+
return false;
|
|
177
|
+
const probe = await probeGateway(state.listener.advertiseHost, state.listener.port);
|
|
178
|
+
if (!probe.reachable) {
|
|
179
|
+
// Stale identity: the process is gone, so clear it instead of erroring.
|
|
180
|
+
updateGatewayState((current) => current.instance?.id === instance.id
|
|
181
|
+
? { ...current, instance: null }
|
|
182
|
+
: current);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (!probe.healthy || probe.instanceId !== instance.id) {
|
|
186
|
+
throw new GatewayControlError("无法验证 gateway 实例身份;为避免误杀,未发送任何进程信号。");
|
|
187
|
+
}
|
|
188
|
+
let response;
|
|
189
|
+
try {
|
|
190
|
+
response = await fetch(controlUrl(state.listener.advertiseHost, state.listener.port, "/_control/shutdown"), {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
"x-llm-switch-control": instance.controlToken,
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ instanceId: instance.id }),
|
|
197
|
+
signal: AbortSignal.timeout(2_000),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new GatewayControlError(`Gateway 协作关闭失败:${error instanceof Error ? error.message : String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
throw new GatewayControlError(`Gateway 拒绝关闭请求(HTTP ${response.status})`);
|
|
205
|
+
}
|
|
206
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
207
|
+
if (readGatewayState().instance?.id !== instance.id)
|
|
208
|
+
return true;
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
210
|
+
}
|
|
211
|
+
throw new GatewayControlError("Gateway 已接受关闭请求,但未在宽限期内清除实例身份");
|
|
212
|
+
}
|
|
213
|
+
export async function runGatewayForeground(host, port, allowRemote = false) {
|
|
214
|
+
ensureDir(getGatewayDir());
|
|
215
|
+
rotateGatewayLogIfNeeded();
|
|
216
|
+
const listener = resolveGatewayListener({ host, port, allowRemote });
|
|
217
|
+
const previous = readGatewayState();
|
|
218
|
+
const instance = {
|
|
219
|
+
id: randomUUID(),
|
|
220
|
+
controlToken: generateGatewayControlToken(),
|
|
221
|
+
pid: process.pid,
|
|
222
|
+
startedAt: new Date().toISOString(),
|
|
223
|
+
};
|
|
224
|
+
updateGatewayState((state) => ({ ...state, listener, instance }));
|
|
225
|
+
let resolveClosed;
|
|
226
|
+
const closed = new Promise((resolve) => {
|
|
227
|
+
resolveClosed = resolve;
|
|
228
|
+
});
|
|
229
|
+
let server = null;
|
|
230
|
+
let closing = false;
|
|
231
|
+
const clearIdentity = () => {
|
|
232
|
+
updateGatewayState((state) => state.instance?.id === instance.id
|
|
233
|
+
? { ...state, instance: null }
|
|
234
|
+
: state);
|
|
235
|
+
};
|
|
236
|
+
const shutdown = async (requestedId = instance.id) => {
|
|
237
|
+
if (requestedId !== instance.id || closing)
|
|
238
|
+
return;
|
|
239
|
+
closing = true;
|
|
240
|
+
if (server) {
|
|
241
|
+
const forceTimer = setTimeout(() => server?.closeAllConnections(), 30_000);
|
|
242
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
243
|
+
clearTimeout(forceTimer);
|
|
244
|
+
}
|
|
245
|
+
clearIdentity();
|
|
246
|
+
resolveClosed?.();
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
server = await listenGateway(listener.port, listener.bindHost, {
|
|
250
|
+
controlToken: instance.controlToken,
|
|
251
|
+
instanceId: instance.id,
|
|
252
|
+
onShutdown: shutdown,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
try {
|
|
257
|
+
clearIdentity();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Preserve the original bind error.
|
|
261
|
+
}
|
|
262
|
+
if (error.code === "EADDRINUSE") {
|
|
263
|
+
throw new GatewayPortOccupiedError(listener.bindHost, listener.port);
|
|
264
|
+
}
|
|
265
|
+
updateGatewayState((state) => state.instance ? state : { ...state, listener: previous.listener });
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
const onSignal = () => {
|
|
269
|
+
void shutdown();
|
|
270
|
+
};
|
|
271
|
+
process.once("SIGINT", onSignal);
|
|
272
|
+
process.once("SIGTERM", onSignal);
|
|
273
|
+
console.error(`llm-switch gateway listening on ${gatewayRootUrl(readGatewayState())}` +
|
|
274
|
+
`(bind ${formatHostForUrl(listener.bindHost)}:${listener.port}${listener.allowRemote ? ",已对外暴露" : ""})`);
|
|
275
|
+
await closed;
|
|
276
|
+
process.removeListener("SIGINT", onSignal);
|
|
277
|
+
process.removeListener("SIGTERM", onSignal);
|
|
278
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format conversion pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Every request is normalised into the hub format (OpenAI Chat Completions),
|
|
5
|
+
* then rendered into the upstream's format; responses travel back the same way.
|
|
6
|
+
* When the inbound and upstream formats match, callers should use raw
|
|
7
|
+
* passthrough instead to avoid a lossy round-trip.
|
|
8
|
+
*/
|
|
9
|
+
import { anthropicToChatRequest } from "./../bridge/anthropic-translate-request.js";
|
|
10
|
+
import { chatChunkToAnthropicEvents, chatCompletionToAnthropicMessage, createAnthropicStreamState, forceCompleteAnthropicStream, } from "./../bridge/anthropic-translate-response.js";
|
|
11
|
+
import { chatToAnthropicRequest } from "./../bridge/chat-to-anthropic-request.js";
|
|
12
|
+
import { anthropicEventToChatChunks, anthropicMessageToChatCompletion, createAnthropicToChatStreamState, forceCompleteAnthropicToChatStream, parseAnthropicSseLine, } from "./../bridge/anthropic-to-chat-response.js";
|
|
13
|
+
import { chatToResponsesRequest } from "./../bridge/chat-to-responses-request.js";
|
|
14
|
+
import { collectCustomToolNames, responsesToChatRequest, } from "./../bridge/translate-request.js";
|
|
15
|
+
import { chatChunkToResponsesEvents, chatCompletionToResponse, createStreamState, forceCompleteStream, parseChatSseLine, } from "./../bridge/translate-response.js";
|
|
16
|
+
import { createResponsesToChatStreamState, forceCompleteResponsesToChatStream, parseResponsesSseLine, responseToChatCompletion, responsesEventToChatChunks, } from "./../bridge/responses-to-chat-response.js";
|
|
17
|
+
export function parseInboundRequest(format, body) {
|
|
18
|
+
return {
|
|
19
|
+
format,
|
|
20
|
+
body,
|
|
21
|
+
requestedModel: String(body.model || ""),
|
|
22
|
+
stream: Boolean(body.stream),
|
|
23
|
+
customTools: format === "openai-responses"
|
|
24
|
+
? collectCustomToolNames(body.tools)
|
|
25
|
+
: new Set(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Inbound body → hub Chat Completions request. */
|
|
29
|
+
export function inboundToChatRequest(inbound) {
|
|
30
|
+
switch (inbound.format) {
|
|
31
|
+
case "openai-chat":
|
|
32
|
+
return { ...inbound.body };
|
|
33
|
+
case "anthropic":
|
|
34
|
+
return anthropicToChatRequest(inbound.body);
|
|
35
|
+
case "openai-responses":
|
|
36
|
+
return responsesToChatRequest(inbound.body);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Upstream request path (relative to the provider base URL). */
|
|
40
|
+
export function upstreamPath(format) {
|
|
41
|
+
switch (format) {
|
|
42
|
+
case "openai-chat":
|
|
43
|
+
return "/chat/completions";
|
|
44
|
+
case "anthropic":
|
|
45
|
+
return "/messages";
|
|
46
|
+
case "openai-responses":
|
|
47
|
+
return "/responses";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Hub Chat Completions request → upstream body for the target format. */
|
|
51
|
+
export function chatRequestToUpstream(format, chat) {
|
|
52
|
+
switch (format) {
|
|
53
|
+
case "openai-chat":
|
|
54
|
+
return chat;
|
|
55
|
+
case "anthropic":
|
|
56
|
+
return chatToAnthropicRequest(chat);
|
|
57
|
+
case "openai-responses":
|
|
58
|
+
return chatToResponsesRequest(chat);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Upstream non-streaming payload → hub Chat Completions object. */
|
|
62
|
+
export function upstreamToChatCompletion(format, payload, fallbackModel) {
|
|
63
|
+
switch (format) {
|
|
64
|
+
case "openai-chat":
|
|
65
|
+
return payload;
|
|
66
|
+
case "anthropic":
|
|
67
|
+
return anthropicMessageToChatCompletion(payload, fallbackModel);
|
|
68
|
+
case "openai-responses":
|
|
69
|
+
return responseToChatCompletion(payload, fallbackModel);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Hub Chat Completions object → inbound response payload. */
|
|
73
|
+
export function chatCompletionToInbound(inbound, chat) {
|
|
74
|
+
switch (inbound.format) {
|
|
75
|
+
case "openai-chat":
|
|
76
|
+
return chat;
|
|
77
|
+
case "anthropic":
|
|
78
|
+
return chatCompletionToAnthropicMessage(chat, inbound.requestedModel);
|
|
79
|
+
case "openai-responses":
|
|
80
|
+
return chatCompletionToResponse(chat, inbound.requestedModel, inbound.customTools, false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Overwrite the model field with the client-requested id so aliases and
|
|
85
|
+
* provider-qualified ids round-trip transparently.
|
|
86
|
+
*/
|
|
87
|
+
export function withRequestedModel(payload, requestedModel) {
|
|
88
|
+
if (!requestedModel)
|
|
89
|
+
return payload;
|
|
90
|
+
if (typeof payload.model === "string") {
|
|
91
|
+
return { ...payload, model: requestedModel };
|
|
92
|
+
}
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
// --- legacy text completions --------------------------------------------------
|
|
96
|
+
/** Normalize `prompt` (string | string[]) into a single user message body. */
|
|
97
|
+
export function legacyPromptToChatBody(body) {
|
|
98
|
+
const prompt = body.prompt;
|
|
99
|
+
const text = Array.isArray(prompt)
|
|
100
|
+
? prompt.map((item) => String(item)).join("\n")
|
|
101
|
+
: prompt === undefined || prompt === null
|
|
102
|
+
? ""
|
|
103
|
+
: String(prompt);
|
|
104
|
+
const { prompt: _ignored, ...rest } = body;
|
|
105
|
+
return {
|
|
106
|
+
...rest,
|
|
107
|
+
messages: [{ role: "user", content: text }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function legacyText(chat) {
|
|
111
|
+
const choices = chat.choices;
|
|
112
|
+
if (!Array.isArray(choices) || !choices.length)
|
|
113
|
+
return "";
|
|
114
|
+
const message = choices[0]?.message;
|
|
115
|
+
const content = message?.content;
|
|
116
|
+
return typeof content === "string" ? content : "";
|
|
117
|
+
}
|
|
118
|
+
function legacyFinishReason(chat) {
|
|
119
|
+
const choices = chat.choices;
|
|
120
|
+
if (!Array.isArray(choices) || !choices.length)
|
|
121
|
+
return null;
|
|
122
|
+
const reason = choices[0]
|
|
123
|
+
?.finish_reason;
|
|
124
|
+
return typeof reason === "string" ? reason : null;
|
|
125
|
+
}
|
|
126
|
+
/** Hub chat completion → legacy `text_completion` payload. */
|
|
127
|
+
export function chatCompletionToLegacyCompletion(chat, requestedModel) {
|
|
128
|
+
return withRequestedModel({
|
|
129
|
+
id: typeof chat.id === "string" ? chat.id : "cmpl-gateway",
|
|
130
|
+
object: "text_completion",
|
|
131
|
+
created: typeof chat.created === "number" ? chat.created : Math.floor(Date.now() / 1000),
|
|
132
|
+
model: requestedModel,
|
|
133
|
+
choices: [
|
|
134
|
+
{
|
|
135
|
+
index: 0,
|
|
136
|
+
text: legacyText(chat),
|
|
137
|
+
logprobs: null,
|
|
138
|
+
finish_reason: legacyFinishReason(chat),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
...(chat.usage ? { usage: chat.usage } : {}),
|
|
142
|
+
}, requestedModel);
|
|
143
|
+
}
|
|
144
|
+
/** Hub chat chunk → legacy `text_completion.chunk` payload. */
|
|
145
|
+
export function chatChunkToLegacyChunk(chunk, requestedModel) {
|
|
146
|
+
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
147
|
+
const first = choices[0] ?? {};
|
|
148
|
+
const delta = first.delta ?? {};
|
|
149
|
+
const text = typeof delta.content === "string" ? delta.content : "";
|
|
150
|
+
return withRequestedModel({
|
|
151
|
+
id: typeof chunk.id === "string" ? chunk.id : "cmpl-gateway",
|
|
152
|
+
object: "text_completion.chunk",
|
|
153
|
+
created: typeof chunk.created === "number"
|
|
154
|
+
? chunk.created
|
|
155
|
+
: Math.floor(Date.now() / 1000),
|
|
156
|
+
model: requestedModel,
|
|
157
|
+
choices: [
|
|
158
|
+
{
|
|
159
|
+
index: 0,
|
|
160
|
+
text,
|
|
161
|
+
logprobs: null,
|
|
162
|
+
finish_reason: typeof first.finish_reason === "string"
|
|
163
|
+
? first.finish_reason
|
|
164
|
+
: null,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}, requestedModel);
|
|
168
|
+
}
|
|
169
|
+
export function createUpstreamDecoder(format, fallbackModel) {
|
|
170
|
+
if (format === "openai-chat") {
|
|
171
|
+
return {
|
|
172
|
+
push(line) {
|
|
173
|
+
const parsed = parseChatSseLine(line);
|
|
174
|
+
if (!parsed || parsed === "done")
|
|
175
|
+
return [];
|
|
176
|
+
return [parsed];
|
|
177
|
+
},
|
|
178
|
+
finish() {
|
|
179
|
+
return [];
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (format === "anthropic") {
|
|
184
|
+
const state = createAnthropicToChatStreamState(fallbackModel);
|
|
185
|
+
return {
|
|
186
|
+
push(line) {
|
|
187
|
+
const parsed = parseAnthropicSseLine(line);
|
|
188
|
+
if (!parsed)
|
|
189
|
+
return [];
|
|
190
|
+
if (parsed === "done")
|
|
191
|
+
return forceCompleteAnthropicToChatStream(state);
|
|
192
|
+
return anthropicEventToChatChunks(parsed, state);
|
|
193
|
+
},
|
|
194
|
+
finish() {
|
|
195
|
+
return forceCompleteAnthropicToChatStream(state);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const state = createResponsesToChatStreamState(fallbackModel);
|
|
200
|
+
return {
|
|
201
|
+
push(line) {
|
|
202
|
+
const parsed = parseResponsesSseLine(line);
|
|
203
|
+
if (!parsed)
|
|
204
|
+
return [];
|
|
205
|
+
if (parsed === "done")
|
|
206
|
+
return forceCompleteResponsesToChatStream(state);
|
|
207
|
+
return responsesEventToChatChunks(parsed, state);
|
|
208
|
+
},
|
|
209
|
+
finish() {
|
|
210
|
+
return forceCompleteResponsesToChatStream(state);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export function createInboundEncoder(inbound) {
|
|
215
|
+
if (inbound.format === "openai-chat" && inbound.legacyCompletion) {
|
|
216
|
+
return {
|
|
217
|
+
encode(chunk) {
|
|
218
|
+
return [
|
|
219
|
+
`data: ${JSON.stringify(chatChunkToLegacyChunk(chunk, inbound.requestedModel))}\n\n`,
|
|
220
|
+
];
|
|
221
|
+
},
|
|
222
|
+
finish() {
|
|
223
|
+
return ["data: [DONE]\n\n"];
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (inbound.format === "openai-chat") {
|
|
228
|
+
return {
|
|
229
|
+
encode(chunk) {
|
|
230
|
+
return [`data: ${JSON.stringify(chunk)}\n\n`];
|
|
231
|
+
},
|
|
232
|
+
finish() {
|
|
233
|
+
return ["data: [DONE]\n\n"];
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (inbound.format === "anthropic") {
|
|
238
|
+
const state = createAnthropicStreamState(inbound.requestedModel);
|
|
239
|
+
return {
|
|
240
|
+
encode(chunk) {
|
|
241
|
+
return chatChunkToAnthropicEvents(chunk, state);
|
|
242
|
+
},
|
|
243
|
+
finish() {
|
|
244
|
+
return forceCompleteAnthropicStream(state);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const state = createStreamState(inbound.requestedModel, undefined, inbound.customTools, false);
|
|
249
|
+
return {
|
|
250
|
+
encode(chunk) {
|
|
251
|
+
return chatChunkToResponsesEvents(chunk, state);
|
|
252
|
+
},
|
|
253
|
+
finish() {
|
|
254
|
+
return forceCompleteStream(state);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** Render an error in the inbound protocol's own error shape. */
|
|
259
|
+
export function formatErrorBody(format, status, message, code = "gateway_error") {
|
|
260
|
+
if (format === "anthropic") {
|
|
261
|
+
return {
|
|
262
|
+
status,
|
|
263
|
+
payload: {
|
|
264
|
+
type: "error",
|
|
265
|
+
error: { type: anthropicErrorType(status, code), message },
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
status,
|
|
271
|
+
payload: {
|
|
272
|
+
error: {
|
|
273
|
+
message,
|
|
274
|
+
type: openAiErrorType(status),
|
|
275
|
+
code,
|
|
276
|
+
param: null,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function openAiErrorType(status) {
|
|
282
|
+
if (status === 401 || status === 403)
|
|
283
|
+
return "authentication_error";
|
|
284
|
+
if (status === 404)
|
|
285
|
+
return "invalid_request_error";
|
|
286
|
+
if (status === 429)
|
|
287
|
+
return "rate_limit_error";
|
|
288
|
+
if (status >= 500)
|
|
289
|
+
return "api_error";
|
|
290
|
+
return "invalid_request_error";
|
|
291
|
+
}
|
|
292
|
+
function anthropicErrorType(status, code) {
|
|
293
|
+
if (status === 401)
|
|
294
|
+
return "authentication_error";
|
|
295
|
+
if (status === 403)
|
|
296
|
+
return "permission_error";
|
|
297
|
+
if (status === 404)
|
|
298
|
+
return "not_found_error";
|
|
299
|
+
if (status === 413)
|
|
300
|
+
return "request_too_large";
|
|
301
|
+
if (status === 429)
|
|
302
|
+
return "rate_limit_error";
|
|
303
|
+
if (status === 529)
|
|
304
|
+
return "overloaded_error";
|
|
305
|
+
if (status >= 500)
|
|
306
|
+
return "api_error";
|
|
307
|
+
return code === "invalid_json" ? "invalid_request_error" : "invalid_request_error";
|
|
308
|
+
}
|
|
309
|
+
/** Wrap an upstream error payload so the client sees its own protocol shape. */
|
|
310
|
+
export function translateUpstreamError(inboundFormat, status, rawBody) {
|
|
311
|
+
let message = rawBody.slice(0, 1000);
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(rawBody);
|
|
314
|
+
const error = parsed.error;
|
|
315
|
+
if (error && typeof error === "object") {
|
|
316
|
+
const row = error;
|
|
317
|
+
if (typeof row.message === "string")
|
|
318
|
+
message = row.message;
|
|
319
|
+
}
|
|
320
|
+
else if (typeof parsed.message === "string") {
|
|
321
|
+
message = parsed.message;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Non-JSON upstream error; keep the truncated raw text.
|
|
326
|
+
}
|
|
327
|
+
return formatErrorBody(inboundFormat, status, message, "upstream_error");
|
|
328
|
+
}
|