@chenpu17/cc-gw 0.2.1
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 +228 -0
- package/package.json +76 -0
- package/src/cli/dist/index.d.ts +1 -0
- package/src/cli/dist/index.js +210 -0
- package/src/server/dist/index.js +2152 -0
- package/src/web/dist/assets/About-DwsCrDAG.js +1 -0
- package/src/web/dist/assets/Dashboard-CX6rHITi.js +61 -0
- package/src/web/dist/assets/Logs-0wlHxVhg.js +1 -0
- package/src/web/dist/assets/ModelManagement-Ckc_KEXy.js +1 -0
- package/src/web/dist/assets/Settings-CeIWDWYw.js +1 -0
- package/src/web/dist/assets/index-BXlilpwV.css +1 -0
- package/src/web/dist/assets/index-tMD4UuQh.js +123 -0
- package/src/web/dist/assets/useApiQuery-BG_l-7WN.js +6 -0
- package/src/web/dist/index.html +13 -0
|
@@ -0,0 +1,2152 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import fastifyCors from "@fastify/cors";
|
|
4
|
+
import fastifyStatic from "@fastify/static";
|
|
5
|
+
import fs3 from "fs";
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
import process2 from "process";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
// config/manager.ts
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import { EventEmitter } from "events";
|
|
15
|
+
var HOME_DIR = path.join(os.homedir(), ".cc-gw");
|
|
16
|
+
var CONFIG_PATH = path.join(HOME_DIR, "config.json");
|
|
17
|
+
var TypedEmitter = class extends EventEmitter {
|
|
18
|
+
on(event, listener) {
|
|
19
|
+
return super.on(event, listener);
|
|
20
|
+
}
|
|
21
|
+
off(event, listener) {
|
|
22
|
+
return super.off(event, listener);
|
|
23
|
+
}
|
|
24
|
+
emitTyped(event, ...args) {
|
|
25
|
+
return super.emit(event, ...args);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var emitter = new TypedEmitter();
|
|
29
|
+
var cachedConfig = null;
|
|
30
|
+
function parseConfig(raw) {
|
|
31
|
+
const data = JSON.parse(raw);
|
|
32
|
+
if (typeof data.port !== "number") {
|
|
33
|
+
throw new Error("\u914D\u7F6E\u6587\u4EF6\u7F3A\u5C11\u6216\u9519\u8BEF\u7684 port \u5B57\u6BB5");
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(data.providers)) {
|
|
36
|
+
data.providers = [];
|
|
37
|
+
}
|
|
38
|
+
if (!data.defaults) {
|
|
39
|
+
data.defaults = {
|
|
40
|
+
completion: null,
|
|
41
|
+
reasoning: null,
|
|
42
|
+
background: null,
|
|
43
|
+
longContextThreshold: 6e4
|
|
44
|
+
};
|
|
45
|
+
} else {
|
|
46
|
+
data.defaults.longContextThreshold ??= 6e4;
|
|
47
|
+
}
|
|
48
|
+
if (typeof data.logRetentionDays !== "number") {
|
|
49
|
+
data.logRetentionDays = 30;
|
|
50
|
+
}
|
|
51
|
+
if (typeof data.storePayloads !== "boolean") {
|
|
52
|
+
data.storePayloads = true;
|
|
53
|
+
}
|
|
54
|
+
if (!data.modelRoutes || typeof data.modelRoutes !== "object") {
|
|
55
|
+
data.modelRoutes = {};
|
|
56
|
+
} else {
|
|
57
|
+
const sanitized = {};
|
|
58
|
+
for (const [key, value] of Object.entries(data.modelRoutes)) {
|
|
59
|
+
if (typeof value !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
const trimmedKey = key.trim();
|
|
62
|
+
const trimmedValue = value.trim();
|
|
63
|
+
if (!trimmedKey || !trimmedValue)
|
|
64
|
+
continue;
|
|
65
|
+
sanitized[trimmedKey] = trimmedValue;
|
|
66
|
+
}
|
|
67
|
+
data.modelRoutes = sanitized;
|
|
68
|
+
}
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
function loadConfig() {
|
|
72
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
73
|
+
throw new Error(`\u914D\u7F6E\u6587\u4EF6\u4E0D\u5B58\u5728: ${CONFIG_PATH}`);
|
|
74
|
+
}
|
|
75
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
76
|
+
cachedConfig = parseConfig(raw);
|
|
77
|
+
return cachedConfig;
|
|
78
|
+
}
|
|
79
|
+
function getConfig() {
|
|
80
|
+
if (cachedConfig)
|
|
81
|
+
return cachedConfig;
|
|
82
|
+
return loadConfig();
|
|
83
|
+
}
|
|
84
|
+
function updateConfig(next) {
|
|
85
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
86
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), "utf-8");
|
|
87
|
+
cachedConfig = next;
|
|
88
|
+
emitter.emitTyped("change", cachedConfig);
|
|
89
|
+
}
|
|
90
|
+
function onConfigChange(listener) {
|
|
91
|
+
emitter.on("change", listener);
|
|
92
|
+
if (cachedConfig)
|
|
93
|
+
listener(cachedConfig);
|
|
94
|
+
return () => emitter.off("change", listener);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// protocol/normalize.ts
|
|
98
|
+
function extractText(content) {
|
|
99
|
+
if (content == null)
|
|
100
|
+
return "";
|
|
101
|
+
if (typeof content === "string")
|
|
102
|
+
return content;
|
|
103
|
+
if (Array.isArray(content)) {
|
|
104
|
+
return content.map((item) => {
|
|
105
|
+
if (typeof item === "string")
|
|
106
|
+
return item;
|
|
107
|
+
if (item && typeof item === "object") {
|
|
108
|
+
if (item.type === "text" && item.text)
|
|
109
|
+
return item.text;
|
|
110
|
+
if (item.content)
|
|
111
|
+
return extractText(item.content);
|
|
112
|
+
}
|
|
113
|
+
return "";
|
|
114
|
+
}).filter(Boolean).join("\n");
|
|
115
|
+
}
|
|
116
|
+
if (typeof content === "object") {
|
|
117
|
+
if (content.text)
|
|
118
|
+
return content.text;
|
|
119
|
+
if (content.content)
|
|
120
|
+
return extractText(content.content);
|
|
121
|
+
}
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
function normalizeSystem(messages, systemField) {
|
|
125
|
+
const systemParts = [];
|
|
126
|
+
if (Array.isArray(systemField)) {
|
|
127
|
+
systemParts.push(...systemField.map((item) => extractText(item)));
|
|
128
|
+
} else if (typeof systemField === "string") {
|
|
129
|
+
systemParts.push(systemField);
|
|
130
|
+
} else if (systemField) {
|
|
131
|
+
systemParts.push(extractText(systemField));
|
|
132
|
+
}
|
|
133
|
+
const remaining = [];
|
|
134
|
+
for (const msg of messages) {
|
|
135
|
+
if (msg.role === "system" || msg.role === "developer") {
|
|
136
|
+
const text = extractText(msg.content);
|
|
137
|
+
if (text)
|
|
138
|
+
systemParts.push(text);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
remaining.push(msg);
|
|
142
|
+
}
|
|
143
|
+
const system = systemParts.filter((part) => part && part.trim().length > 0).join("\n\n") || null;
|
|
144
|
+
return { system, remaining };
|
|
145
|
+
}
|
|
146
|
+
function normalizeClaudePayload(payload) {
|
|
147
|
+
const stream = Boolean(payload.stream);
|
|
148
|
+
const thinking = Boolean(payload.thinking);
|
|
149
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : payload.messages ? [payload.messages] : [];
|
|
150
|
+
const { system, remaining } = normalizeSystem(messages, payload.system);
|
|
151
|
+
const normalizedMessages = remaining.map((msg) => {
|
|
152
|
+
if (msg.role === "user") {
|
|
153
|
+
if (Array.isArray(msg.content)) {
|
|
154
|
+
const textParts = [];
|
|
155
|
+
const toolResults = [];
|
|
156
|
+
for (const block of msg.content) {
|
|
157
|
+
if (block.type === "text" && block.text) {
|
|
158
|
+
textParts.push(block.text);
|
|
159
|
+
} else if (block.type === "tool_result") {
|
|
160
|
+
toolResults.push({
|
|
161
|
+
id: block.tool_use_id || block.id || "tool_result",
|
|
162
|
+
name: block.name,
|
|
163
|
+
content: block.content ?? block.text ?? null,
|
|
164
|
+
cacheControl: block.cache_control
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
role: "user",
|
|
170
|
+
text: textParts.join("\n"),
|
|
171
|
+
toolResults
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
role: "user",
|
|
176
|
+
text: extractText(msg.content)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (msg.role === "assistant") {
|
|
180
|
+
const toolCalls = [];
|
|
181
|
+
let text = "";
|
|
182
|
+
if (Array.isArray(msg.content)) {
|
|
183
|
+
const textParts = [];
|
|
184
|
+
for (const block of msg.content) {
|
|
185
|
+
if (block.type === "text" && block.text) {
|
|
186
|
+
textParts.push(block.text);
|
|
187
|
+
} else if (block.type === "tool_use") {
|
|
188
|
+
toolCalls.push({
|
|
189
|
+
id: block.id || `call_${Math.random().toString(36).slice(2)}`,
|
|
190
|
+
name: block.name,
|
|
191
|
+
arguments: block.input,
|
|
192
|
+
cacheControl: block.cache_control
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
text = textParts.join("\n");
|
|
197
|
+
} else if (typeof msg.content === "string") {
|
|
198
|
+
text = msg.content;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
role: "assistant",
|
|
202
|
+
text,
|
|
203
|
+
toolCalls
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
role: "user",
|
|
208
|
+
text: extractText(msg.content)
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
original: payload,
|
|
213
|
+
system,
|
|
214
|
+
messages: normalizedMessages,
|
|
215
|
+
tools: Array.isArray(payload.tools) ? payload.tools : [],
|
|
216
|
+
stream,
|
|
217
|
+
thinking
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// protocol/tokenizer.ts
|
|
222
|
+
import { encoding_for_model } from "tiktoken";
|
|
223
|
+
function getEncoder(model) {
|
|
224
|
+
try {
|
|
225
|
+
return encoding_for_model(model);
|
|
226
|
+
} catch {
|
|
227
|
+
return encoding_for_model("gpt-3.5-turbo");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function estimateTextTokens(text, model) {
|
|
231
|
+
if (!text)
|
|
232
|
+
return 0;
|
|
233
|
+
try {
|
|
234
|
+
const encoder2 = getEncoder(model);
|
|
235
|
+
return encoder2.encode(text).length;
|
|
236
|
+
} catch {
|
|
237
|
+
return Math.ceil(text.length / 4);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function estimateTokens(payload, model) {
|
|
241
|
+
try {
|
|
242
|
+
const encoder2 = getEncoder(model);
|
|
243
|
+
let total = 0;
|
|
244
|
+
if (payload.system) {
|
|
245
|
+
total += encoder2.encode(payload.system).length;
|
|
246
|
+
}
|
|
247
|
+
for (const message of payload.messages) {
|
|
248
|
+
if (message.text) {
|
|
249
|
+
total += encoder2.encode(message.text).length;
|
|
250
|
+
}
|
|
251
|
+
if (message.toolCalls) {
|
|
252
|
+
for (const call of message.toolCalls) {
|
|
253
|
+
total += encoder2.encode(JSON.stringify(call.arguments ?? {})).length;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (message.toolResults) {
|
|
257
|
+
for (const result of message.toolResults) {
|
|
258
|
+
total += encoder2.encode(JSON.stringify(result.content ?? "")).length;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return total;
|
|
263
|
+
} catch {
|
|
264
|
+
const text = [payload.system ?? "", ...payload.messages.map((m) => m.text ?? "")].join("\n");
|
|
265
|
+
return Math.ceil(text.length / 4);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// router/index.ts
|
|
270
|
+
function resolveByIdentifier(identifier, providers) {
|
|
271
|
+
if (!identifier)
|
|
272
|
+
return null;
|
|
273
|
+
if (identifier.includes(":")) {
|
|
274
|
+
const [providerId, modelId] = identifier.split(":", 2);
|
|
275
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
276
|
+
if (provider && (provider.defaultModel === modelId || provider.models?.some((m) => m.id === modelId))) {
|
|
277
|
+
return { providerId, modelId, provider, tokenEstimate: 0 };
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
for (const provider of providers) {
|
|
281
|
+
if (provider.defaultModel === identifier || provider.models?.some((m) => m.id === identifier)) {
|
|
282
|
+
return { providerId: provider.id, modelId: identifier, provider, tokenEstimate: 0 };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
function resolveRoute(ctx) {
|
|
289
|
+
const config = getConfig();
|
|
290
|
+
const providers = config.providers;
|
|
291
|
+
if (!providers.length) {
|
|
292
|
+
throw new Error("\u672A\u914D\u7F6E\u4EFB\u4F55\u6A21\u578B\u63D0\u4F9B\u5546\uFF0C\u8BF7\u5148\u5728 Web UI \u4E2D\u6DFB\u52A0 Provider\u3002");
|
|
293
|
+
}
|
|
294
|
+
const requestedModel = ctx.requestedModel?.trim();
|
|
295
|
+
const mappedIdentifier = requestedModel ? config.modelRoutes?.[requestedModel] ?? null : null;
|
|
296
|
+
const fallbackModelId = providers[0].defaultModel ?? providers[0].models?.[0]?.id ?? "gpt-4o";
|
|
297
|
+
const tokenEstimate = estimateTokens(
|
|
298
|
+
ctx.payload,
|
|
299
|
+
mappedIdentifier ?? requestedModel ?? fallbackModelId
|
|
300
|
+
);
|
|
301
|
+
const strategy = ctx.payload;
|
|
302
|
+
const defaults = config.defaults;
|
|
303
|
+
if (mappedIdentifier) {
|
|
304
|
+
const mapped = resolveByIdentifier(mappedIdentifier, providers);
|
|
305
|
+
if (mapped) {
|
|
306
|
+
return { ...mapped, tokenEstimate };
|
|
307
|
+
}
|
|
308
|
+
console.warn(`modelRoutes \u6620\u5C04\u76EE\u6807\u65E0\u6548: ${mappedIdentifier}`);
|
|
309
|
+
}
|
|
310
|
+
const fromRequest = resolveByIdentifier(requestedModel, providers);
|
|
311
|
+
if (fromRequest) {
|
|
312
|
+
return { ...fromRequest, tokenEstimate };
|
|
313
|
+
}
|
|
314
|
+
if (strategy.thinking && defaults.reasoning) {
|
|
315
|
+
const target = resolveByIdentifier(defaults.reasoning, providers);
|
|
316
|
+
if (target)
|
|
317
|
+
return { ...target, tokenEstimate };
|
|
318
|
+
}
|
|
319
|
+
if (tokenEstimate > (defaults.longContextThreshold ?? 6e4) && defaults.background) {
|
|
320
|
+
const target = resolveByIdentifier(defaults.background, providers);
|
|
321
|
+
if (target)
|
|
322
|
+
return { ...target, tokenEstimate };
|
|
323
|
+
}
|
|
324
|
+
if (defaults.completion) {
|
|
325
|
+
const target = resolveByIdentifier(defaults.completion, providers);
|
|
326
|
+
if (target)
|
|
327
|
+
return { ...target, tokenEstimate };
|
|
328
|
+
}
|
|
329
|
+
const firstProvider = providers[0];
|
|
330
|
+
const modelId = firstProvider.defaultModel || firstProvider.models?.[0]?.id;
|
|
331
|
+
if (!modelId) {
|
|
332
|
+
throw new Error(`Provider ${firstProvider.id} \u672A\u914D\u7F6E\u4EFB\u4F55\u6A21\u578B`);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
providerId: firstProvider.id,
|
|
336
|
+
modelId,
|
|
337
|
+
provider: firstProvider,
|
|
338
|
+
tokenEstimate
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// protocol/toProvider.ts
|
|
343
|
+
function buildMessages(payload) {
|
|
344
|
+
const messages = [];
|
|
345
|
+
if (payload.system) {
|
|
346
|
+
messages.push({ role: "system", content: payload.system });
|
|
347
|
+
}
|
|
348
|
+
for (const message of payload.messages) {
|
|
349
|
+
if (message.role === "user") {
|
|
350
|
+
if (message.toolResults?.length) {
|
|
351
|
+
for (const tool of message.toolResults) {
|
|
352
|
+
const serialized = typeof tool.content === "string" ? tool.content : JSON.stringify(tool.content ?? "");
|
|
353
|
+
messages.push({
|
|
354
|
+
role: "tool",
|
|
355
|
+
tool_call_id: tool.id,
|
|
356
|
+
name: tool.name ?? tool.id,
|
|
357
|
+
content: serialized ?? "",
|
|
358
|
+
cache_control: tool.cacheControl
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const userContent = message.text ?? "";
|
|
363
|
+
const hasUserText = userContent.trim().length > 0;
|
|
364
|
+
if (hasUserText || !message.toolResults?.length) {
|
|
365
|
+
messages.push({ role: "user", content: hasUserText ? userContent : "" });
|
|
366
|
+
}
|
|
367
|
+
} else if (message.role === "assistant") {
|
|
368
|
+
const openAiMsg = {
|
|
369
|
+
role: "assistant",
|
|
370
|
+
content: message.text ?? ""
|
|
371
|
+
};
|
|
372
|
+
if (message.toolCalls?.length) {
|
|
373
|
+
openAiMsg.tool_calls = message.toolCalls.map((call) => ({
|
|
374
|
+
id: call.id,
|
|
375
|
+
type: "function",
|
|
376
|
+
function: {
|
|
377
|
+
name: call.name,
|
|
378
|
+
arguments: typeof call.arguments === "string" ? call.arguments : JSON.stringify(call.arguments ?? {})
|
|
379
|
+
},
|
|
380
|
+
cache_control: call.cacheControl
|
|
381
|
+
}));
|
|
382
|
+
if (!openAiMsg.content) {
|
|
383
|
+
openAiMsg.content = null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
messages.push(openAiMsg);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return messages;
|
|
390
|
+
}
|
|
391
|
+
function buildProviderBody(payload, options = {}) {
|
|
392
|
+
const body = {
|
|
393
|
+
messages: buildMessages(payload)
|
|
394
|
+
};
|
|
395
|
+
if (options.maxTokens) {
|
|
396
|
+
if (payload.thinking) {
|
|
397
|
+
body.max_completion_tokens = options.maxTokens;
|
|
398
|
+
} else {
|
|
399
|
+
body.max_tokens = options.maxTokens;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (typeof options.temperature === "number") {
|
|
403
|
+
body.temperature = options.temperature;
|
|
404
|
+
}
|
|
405
|
+
const tools = options.overrideTools ?? payload.tools;
|
|
406
|
+
if (tools && tools.length > 0) {
|
|
407
|
+
body.tools = tools.map((tool) => ({
|
|
408
|
+
type: "function",
|
|
409
|
+
function: {
|
|
410
|
+
name: tool.name,
|
|
411
|
+
description: tool.description,
|
|
412
|
+
parameters: tool.input_schema ?? tool.parameters ?? {}
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
if (options.toolChoice) {
|
|
417
|
+
body.tool_choice = options.toolChoice;
|
|
418
|
+
}
|
|
419
|
+
const passthroughKeys = [
|
|
420
|
+
"cache_control",
|
|
421
|
+
"metadata",
|
|
422
|
+
"response_format",
|
|
423
|
+
"parallel_tool_calls",
|
|
424
|
+
"frequency_penalty",
|
|
425
|
+
"presence_penalty",
|
|
426
|
+
"logit_bias",
|
|
427
|
+
"top_p",
|
|
428
|
+
"top_k",
|
|
429
|
+
"stop",
|
|
430
|
+
"stop_sequences",
|
|
431
|
+
"user",
|
|
432
|
+
"seed",
|
|
433
|
+
"n",
|
|
434
|
+
"options"
|
|
435
|
+
];
|
|
436
|
+
const original = payload.original ?? {};
|
|
437
|
+
for (const key of passthroughKeys) {
|
|
438
|
+
if (Object.prototype.hasOwnProperty.call(original, key)) {
|
|
439
|
+
const value = original[key];
|
|
440
|
+
if (value !== void 0) {
|
|
441
|
+
;
|
|
442
|
+
body[key] = value;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return body;
|
|
447
|
+
}
|
|
448
|
+
function buildAnthropicContentFromText(text) {
|
|
449
|
+
if (!text || text.length === 0) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
return [
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
text
|
|
456
|
+
}
|
|
457
|
+
];
|
|
458
|
+
}
|
|
459
|
+
function buildAnthropicBody(payload, options = {}) {
|
|
460
|
+
const messages = [];
|
|
461
|
+
for (const message of payload.messages) {
|
|
462
|
+
const blocks = [];
|
|
463
|
+
if (message.text) {
|
|
464
|
+
blocks.push(...buildAnthropicContentFromText(message.text));
|
|
465
|
+
}
|
|
466
|
+
if (message.role === "user" && message.toolResults?.length) {
|
|
467
|
+
for (const result of message.toolResults) {
|
|
468
|
+
const content = typeof result.content === "string" ? [{ type: "text", text: result.content }] : [{ type: "text", text: JSON.stringify(result.content ?? "") }];
|
|
469
|
+
blocks.push({
|
|
470
|
+
type: "tool_result",
|
|
471
|
+
tool_use_id: result.id,
|
|
472
|
+
content,
|
|
473
|
+
cache_control: result.cacheControl
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (message.role === "assistant" && message.toolCalls?.length) {
|
|
478
|
+
for (const call of message.toolCalls) {
|
|
479
|
+
blocks.push({
|
|
480
|
+
type: "tool_use",
|
|
481
|
+
id: call.id,
|
|
482
|
+
name: call.name,
|
|
483
|
+
input: call.arguments ?? {},
|
|
484
|
+
cache_control: call.cacheControl
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (message.role === "assistant" || message.role === "user") {
|
|
489
|
+
if (blocks.length === 0) {
|
|
490
|
+
blocks.push({ type: "text", text: "" });
|
|
491
|
+
}
|
|
492
|
+
messages.push({
|
|
493
|
+
role: message.role,
|
|
494
|
+
content: blocks
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const body = {
|
|
499
|
+
system: payload.system ?? void 0,
|
|
500
|
+
messages
|
|
501
|
+
};
|
|
502
|
+
if (options.maxTokens) {
|
|
503
|
+
body.max_tokens = options.maxTokens;
|
|
504
|
+
}
|
|
505
|
+
if (typeof options.temperature === "number") {
|
|
506
|
+
body.temperature = options.temperature;
|
|
507
|
+
}
|
|
508
|
+
const tools = options.overrideTools ?? payload.tools;
|
|
509
|
+
if (tools && tools.length > 0) {
|
|
510
|
+
body.tools = tools.map((tool) => ({
|
|
511
|
+
type: "tool",
|
|
512
|
+
name: tool.name,
|
|
513
|
+
description: tool.description,
|
|
514
|
+
input_schema: tool.input_schema ?? tool.parameters ?? {}
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
if (options.toolChoice) {
|
|
518
|
+
body.tool_choice = options.toolChoice;
|
|
519
|
+
}
|
|
520
|
+
return body;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// providers/openai.ts
|
|
524
|
+
import { fetch } from "undici";
|
|
525
|
+
import { ReadableStream } from "stream/web";
|
|
526
|
+
var encoder = new TextEncoder();
|
|
527
|
+
function createJsonStream(payload) {
|
|
528
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
529
|
+
return new ReadableStream({
|
|
530
|
+
start(controller) {
|
|
531
|
+
controller.enqueue(encoder.encode(text));
|
|
532
|
+
controller.close();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function resolveEndpoint(config, options) {
|
|
537
|
+
if (options?.endpoint)
|
|
538
|
+
return options.endpoint;
|
|
539
|
+
const base = config.baseUrl.replace(/\/$/, "");
|
|
540
|
+
const defaultPath = options?.defaultPath ?? "v1/chat/completions";
|
|
541
|
+
if (base.endsWith("/chat/completions"))
|
|
542
|
+
return base;
|
|
543
|
+
let pathSegment = defaultPath;
|
|
544
|
+
const versionMatch = base.match(/\/v(\d+)$/);
|
|
545
|
+
if (versionMatch && defaultPath.startsWith("v1/")) {
|
|
546
|
+
pathSegment = defaultPath.slice(3);
|
|
547
|
+
}
|
|
548
|
+
if (pathSegment.startsWith("/")) {
|
|
549
|
+
pathSegment = pathSegment.slice(1);
|
|
550
|
+
}
|
|
551
|
+
return `${base}/${pathSegment}`;
|
|
552
|
+
}
|
|
553
|
+
function createOpenAIConnector(config, options) {
|
|
554
|
+
const url = resolveEndpoint(config, options);
|
|
555
|
+
const shouldLogEndpoint = process.env.CC_GW_DEBUG_ENDPOINTS === "1";
|
|
556
|
+
return {
|
|
557
|
+
id: config.id,
|
|
558
|
+
async send(request) {
|
|
559
|
+
const headers = {
|
|
560
|
+
"Content-Type": "application/json",
|
|
561
|
+
...config.extraHeaders,
|
|
562
|
+
...request.headers
|
|
563
|
+
};
|
|
564
|
+
if (config.apiKey) {
|
|
565
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
566
|
+
}
|
|
567
|
+
const body = {
|
|
568
|
+
...request.body,
|
|
569
|
+
model: request.model,
|
|
570
|
+
stream: request.stream ?? false
|
|
571
|
+
};
|
|
572
|
+
const payload = options?.mutateBody ? options.mutateBody(body) : body;
|
|
573
|
+
if (shouldLogEndpoint) {
|
|
574
|
+
console.info(`[cc-gw] provider=${config.id} endpoint=${url}`);
|
|
575
|
+
}
|
|
576
|
+
const res = await fetch(url, {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers,
|
|
579
|
+
body: JSON.stringify(payload)
|
|
580
|
+
});
|
|
581
|
+
if (shouldLogEndpoint) {
|
|
582
|
+
console.info(`[cc-gw] provider=${config.id} status=${res.status}`);
|
|
583
|
+
}
|
|
584
|
+
if (res.status >= 400 && options?.mapErrorBody) {
|
|
585
|
+
let raw = null;
|
|
586
|
+
try {
|
|
587
|
+
raw = await res.json();
|
|
588
|
+
} catch {
|
|
589
|
+
raw = await res.text();
|
|
590
|
+
}
|
|
591
|
+
if (shouldLogEndpoint) {
|
|
592
|
+
console.warn(`[cc-gw] provider=${config.id} error_body=${typeof raw === "string" ? raw : JSON.stringify(raw)}`);
|
|
593
|
+
}
|
|
594
|
+
const mapped = options.mapErrorBody(raw);
|
|
595
|
+
return {
|
|
596
|
+
status: res.status,
|
|
597
|
+
headers: res.headers,
|
|
598
|
+
body: createJsonStream(mapped)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
status: res.status,
|
|
603
|
+
headers: res.headers,
|
|
604
|
+
body: res.body
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// providers/kimi.ts
|
|
611
|
+
var codeMessageMap = {
|
|
612
|
+
invalid_api_key: "Kimi API Key \u65E0\u6548\uFF0C\u8BF7\u786E\u8BA4\u5728\u63A7\u5236\u53F0\u590D\u5236\u7684\u503C\u662F\u5426\u6B63\u786E",
|
|
613
|
+
permission_denied: "Kimi API \u6743\u9650\u4E0D\u8DB3\u6216\u8D26\u53F7\u72B6\u6001\u5F02\u5E38",
|
|
614
|
+
insufficient_quota: "Kimi \u914D\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u524D\u5F80\u6708\u4E4B\u6697\u9762\u63A7\u5236\u53F0\u5145\u503C",
|
|
615
|
+
rate_limit_exceeded: "Kimi \u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5"
|
|
616
|
+
};
|
|
617
|
+
function mapKimiError(payload) {
|
|
618
|
+
if (typeof payload === "string") {
|
|
619
|
+
try {
|
|
620
|
+
return mapKimiError(JSON.parse(payload));
|
|
621
|
+
} catch {
|
|
622
|
+
return { error: { message: payload, code: "unknown_error" } };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const data = payload;
|
|
626
|
+
const code = data?.error?.code ?? data?.error?.type ?? "unknown_error";
|
|
627
|
+
const message = codeMessageMap[code] ?? data?.error?.message ?? "Kimi \u8BF7\u6C42\u5931\u8D25";
|
|
628
|
+
return {
|
|
629
|
+
error: {
|
|
630
|
+
code,
|
|
631
|
+
message
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function createKimiConnector(config) {
|
|
636
|
+
const base = config.baseUrl.replace(/\/$/, "");
|
|
637
|
+
const endpointBase = base.endsWith("/v1") ? base.slice(0, -3) : base;
|
|
638
|
+
const endpoint = `${endpointBase}/v1/chat/completions`;
|
|
639
|
+
if (process.env.CC_GW_DEBUG_ENDPOINTS === "1") {
|
|
640
|
+
console.info(`[cc-gw] kimi connector base=${config.baseUrl} resolved=${endpoint}`);
|
|
641
|
+
}
|
|
642
|
+
return createOpenAIConnector(config, {
|
|
643
|
+
endpoint,
|
|
644
|
+
mapErrorBody: mapKimiError
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// providers/deepseek.ts
|
|
649
|
+
var codeMessageMap2 = {
|
|
650
|
+
authentication_error: "DeepSeek API Key \u65E0\u6548\u6216\u672A\u914D\u7F6E",
|
|
651
|
+
permission_denied: "DeepSeek API Key \u6743\u9650\u4E0D\u8DB3\uFF0C\u8BF7\u68C0\u67E5\u8BA2\u9605\u8BA1\u5212",
|
|
652
|
+
rate_limit_exceeded: "DeepSeek \u8BF7\u6C42\u9891\u7387\u5DF2\u8FBE\u4E0A\u9650\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5",
|
|
653
|
+
insufficient_quota: "DeepSeek \u8D26\u6237\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528"
|
|
654
|
+
};
|
|
655
|
+
function mapDeepSeekError(payload) {
|
|
656
|
+
if (typeof payload === "string") {
|
|
657
|
+
try {
|
|
658
|
+
return mapDeepSeekError(JSON.parse(payload));
|
|
659
|
+
} catch {
|
|
660
|
+
return { error: { message: payload, code: "unknown_error" } };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const data = payload;
|
|
664
|
+
const code = data?.error?.code ?? data?.error?.type ?? "unknown_error";
|
|
665
|
+
const mappedMessage = codeMessageMap2[code] ?? data?.error?.message ?? "DeepSeek \u8BF7\u6C42\u5931\u8D25";
|
|
666
|
+
return {
|
|
667
|
+
error: {
|
|
668
|
+
code,
|
|
669
|
+
message: mappedMessage
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function createDeepSeekConnector(config) {
|
|
674
|
+
return createOpenAIConnector(config, {
|
|
675
|
+
defaultPath: "v1/chat/completions",
|
|
676
|
+
mapErrorBody: mapDeepSeekError
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// providers/anthropic.ts
|
|
681
|
+
import { fetch as fetch2 } from "undici";
|
|
682
|
+
var DEFAULT_VERSION = "2023-06-01";
|
|
683
|
+
function createAnthropicConnector(config) {
|
|
684
|
+
const baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
685
|
+
return {
|
|
686
|
+
id: config.id,
|
|
687
|
+
async send(request) {
|
|
688
|
+
const headers = {
|
|
689
|
+
"Content-Type": "application/json",
|
|
690
|
+
"anthropic-version": DEFAULT_VERSION,
|
|
691
|
+
...config.extraHeaders,
|
|
692
|
+
...request.headers
|
|
693
|
+
};
|
|
694
|
+
delete headers.Authorization;
|
|
695
|
+
if (config.apiKey) {
|
|
696
|
+
headers["x-api-key"] = config.apiKey;
|
|
697
|
+
}
|
|
698
|
+
if (!headers["anthropic-version"]) {
|
|
699
|
+
headers["anthropic-version"] = DEFAULT_VERSION;
|
|
700
|
+
}
|
|
701
|
+
const payload = {
|
|
702
|
+
...request.body,
|
|
703
|
+
model: request.model,
|
|
704
|
+
stream: request.stream ?? false
|
|
705
|
+
};
|
|
706
|
+
const response = await fetch2(`${baseUrl}/messages`, {
|
|
707
|
+
method: "POST",
|
|
708
|
+
headers,
|
|
709
|
+
body: JSON.stringify(payload)
|
|
710
|
+
});
|
|
711
|
+
return {
|
|
712
|
+
status: response.status,
|
|
713
|
+
headers: response.headers,
|
|
714
|
+
body: response.body
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// providers/registry.ts
|
|
721
|
+
var connectors = /* @__PURE__ */ new Map();
|
|
722
|
+
function buildConnector(config) {
|
|
723
|
+
switch (config.type) {
|
|
724
|
+
case "deepseek":
|
|
725
|
+
return createDeepSeekConnector(config);
|
|
726
|
+
case "kimi":
|
|
727
|
+
return createKimiConnector(config);
|
|
728
|
+
case "anthropic":
|
|
729
|
+
return createAnthropicConnector(config);
|
|
730
|
+
case "openai":
|
|
731
|
+
case "custom":
|
|
732
|
+
default:
|
|
733
|
+
return createOpenAIConnector(config);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function rebuildConnectors() {
|
|
737
|
+
const config = getConfig();
|
|
738
|
+
connectors = new Map(config.providers.map((provider) => [provider.id, buildConnector(provider)]));
|
|
739
|
+
}
|
|
740
|
+
rebuildConnectors();
|
|
741
|
+
onConfigChange(() => rebuildConnectors());
|
|
742
|
+
function getConnector(providerId) {
|
|
743
|
+
const connector = connectors.get(providerId);
|
|
744
|
+
if (!connector) {
|
|
745
|
+
throw new Error(`\u672A\u627E\u5230 provider: ${providerId}`);
|
|
746
|
+
}
|
|
747
|
+
return connector;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// logging/logger.ts
|
|
751
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "zlib";
|
|
752
|
+
|
|
753
|
+
// storage/index.ts
|
|
754
|
+
import fs2 from "fs";
|
|
755
|
+
import os2 from "os";
|
|
756
|
+
import path2 from "path";
|
|
757
|
+
import Database from "better-sqlite3";
|
|
758
|
+
var HOME_DIR2 = path2.join(os2.homedir(), ".cc-gw");
|
|
759
|
+
var DATA_DIR = path2.join(HOME_DIR2, "data");
|
|
760
|
+
var DB_PATH = path2.join(DATA_DIR, "gateway.db");
|
|
761
|
+
var db = null;
|
|
762
|
+
function ensureSchema(instance) {
|
|
763
|
+
instance.exec(`
|
|
764
|
+
CREATE TABLE IF NOT EXISTS request_logs (
|
|
765
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
766
|
+
timestamp INTEGER NOT NULL,
|
|
767
|
+
session_id TEXT,
|
|
768
|
+
provider TEXT NOT NULL,
|
|
769
|
+
model TEXT NOT NULL,
|
|
770
|
+
client_model TEXT,
|
|
771
|
+
latency_ms INTEGER,
|
|
772
|
+
status_code INTEGER,
|
|
773
|
+
input_tokens INTEGER,
|
|
774
|
+
output_tokens INTEGER,
|
|
775
|
+
cached_tokens INTEGER,
|
|
776
|
+
ttft_ms INTEGER,
|
|
777
|
+
tpot_ms REAL,
|
|
778
|
+
error TEXT
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
CREATE TABLE IF NOT EXISTS request_payloads (
|
|
782
|
+
request_id INTEGER PRIMARY KEY,
|
|
783
|
+
prompt TEXT,
|
|
784
|
+
response TEXT,
|
|
785
|
+
FOREIGN KEY(request_id) REFERENCES request_logs(id) ON DELETE CASCADE
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
CREATE TABLE IF NOT EXISTS daily_metrics (
|
|
789
|
+
date TEXT PRIMARY KEY,
|
|
790
|
+
request_count INTEGER DEFAULT 0,
|
|
791
|
+
total_input_tokens INTEGER DEFAULT 0,
|
|
792
|
+
total_output_tokens INTEGER DEFAULT 0,
|
|
793
|
+
total_latency_ms INTEGER DEFAULT 0
|
|
794
|
+
);
|
|
795
|
+
`);
|
|
796
|
+
}
|
|
797
|
+
function getDb() {
|
|
798
|
+
if (db)
|
|
799
|
+
return db;
|
|
800
|
+
fs2.mkdirSync(DATA_DIR, { recursive: true });
|
|
801
|
+
db = new Database(DB_PATH);
|
|
802
|
+
ensureSchema(db);
|
|
803
|
+
ensureColumns(db);
|
|
804
|
+
return db;
|
|
805
|
+
}
|
|
806
|
+
function ensureColumns(instance) {
|
|
807
|
+
const columns = instance.prepare("PRAGMA table_info(request_logs)").all();
|
|
808
|
+
const hasCachedTokens = columns.some((column) => column.name === "cached_tokens");
|
|
809
|
+
if (!hasCachedTokens) {
|
|
810
|
+
instance.exec("ALTER TABLE request_logs ADD COLUMN cached_tokens INTEGER");
|
|
811
|
+
}
|
|
812
|
+
const hasClientModel = columns.some((column) => column.name === "client_model");
|
|
813
|
+
if (!hasClientModel) {
|
|
814
|
+
instance.exec("ALTER TABLE request_logs ADD COLUMN client_model TEXT");
|
|
815
|
+
}
|
|
816
|
+
const hasTtft = columns.some((column) => column.name === "ttft_ms");
|
|
817
|
+
if (!hasTtft) {
|
|
818
|
+
instance.exec("ALTER TABLE request_logs ADD COLUMN ttft_ms INTEGER");
|
|
819
|
+
}
|
|
820
|
+
const hasTpot = columns.some((column) => column.name === "tpot_ms");
|
|
821
|
+
if (!hasTpot) {
|
|
822
|
+
instance.exec("ALTER TABLE request_logs ADD COLUMN tpot_ms REAL");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// logging/logger.ts
|
|
827
|
+
function recordLog(entry) {
|
|
828
|
+
const db2 = getDb();
|
|
829
|
+
const stmt = db2.prepare(`
|
|
830
|
+
INSERT INTO request_logs (
|
|
831
|
+
timestamp, session_id, provider, model, client_model,
|
|
832
|
+
latency_ms, status_code, input_tokens, output_tokens, cached_tokens, error
|
|
833
|
+
) VALUES (@timestamp, @sessionId, @provider, @model, @clientModel, @latencyMs, @statusCode, @inputTokens, @outputTokens, @cachedTokens, @error)
|
|
834
|
+
`);
|
|
835
|
+
const result = stmt.run({
|
|
836
|
+
timestamp: entry.timestamp,
|
|
837
|
+
sessionId: entry.sessionId ?? null,
|
|
838
|
+
provider: entry.provider,
|
|
839
|
+
model: entry.model,
|
|
840
|
+
clientModel: entry.clientModel ?? null,
|
|
841
|
+
latencyMs: entry.latencyMs ?? null,
|
|
842
|
+
statusCode: entry.statusCode ?? null,
|
|
843
|
+
inputTokens: entry.inputTokens ?? null,
|
|
844
|
+
outputTokens: entry.outputTokens ?? null,
|
|
845
|
+
cachedTokens: entry.cachedTokens ?? null,
|
|
846
|
+
error: entry.error ?? null
|
|
847
|
+
});
|
|
848
|
+
const requestId = Number(result.lastInsertRowid);
|
|
849
|
+
return requestId;
|
|
850
|
+
}
|
|
851
|
+
var BROTLI_OPTIONS = {
|
|
852
|
+
params: {
|
|
853
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 1
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
function compressPayload(value) {
|
|
857
|
+
if (value === void 0 || value === null) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
if (value.length === 0) {
|
|
861
|
+
return Buffer.alloc(0);
|
|
862
|
+
}
|
|
863
|
+
return brotliCompressSync(Buffer.from(value, "utf8"), BROTLI_OPTIONS);
|
|
864
|
+
}
|
|
865
|
+
function decompressPayload(value) {
|
|
866
|
+
if (value === void 0 || value === null) {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
if (typeof value === "string") {
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
if (Buffer.isBuffer(value)) {
|
|
873
|
+
if (value.length === 0) {
|
|
874
|
+
return "";
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const decompressed = brotliDecompressSync(value);
|
|
878
|
+
return decompressed.toString("utf8");
|
|
879
|
+
} catch {
|
|
880
|
+
return value.toString("utf8");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
function updateLogTokens(requestId, values) {
|
|
886
|
+
const db2 = getDb();
|
|
887
|
+
const setters = ["input_tokens = ?", "output_tokens = ?", "cached_tokens = ?"];
|
|
888
|
+
const params = [
|
|
889
|
+
values.inputTokens,
|
|
890
|
+
values.outputTokens,
|
|
891
|
+
values.cachedTokens ?? null
|
|
892
|
+
];
|
|
893
|
+
if (values.ttftMs !== void 0) {
|
|
894
|
+
setters.push("ttft_ms = ?");
|
|
895
|
+
params.push(values.ttftMs ?? null);
|
|
896
|
+
}
|
|
897
|
+
if (values.tpotMs !== void 0) {
|
|
898
|
+
setters.push("tpot_ms = ?");
|
|
899
|
+
params.push(values.tpotMs ?? null);
|
|
900
|
+
}
|
|
901
|
+
db2.prepare(`UPDATE request_logs SET ${setters.join(", ")} WHERE id = ?`).run(...params, requestId);
|
|
902
|
+
}
|
|
903
|
+
function finalizeLog(requestId, info) {
|
|
904
|
+
const db2 = getDb();
|
|
905
|
+
const setters = [];
|
|
906
|
+
const values = [];
|
|
907
|
+
if (info.latencyMs !== void 0) {
|
|
908
|
+
setters.push("latency_ms = ?");
|
|
909
|
+
values.push(info.latencyMs);
|
|
910
|
+
}
|
|
911
|
+
if (info.statusCode !== void 0) {
|
|
912
|
+
setters.push("status_code = ?");
|
|
913
|
+
values.push(info.statusCode ?? null);
|
|
914
|
+
}
|
|
915
|
+
if (info.error !== void 0) {
|
|
916
|
+
setters.push("error = ?");
|
|
917
|
+
values.push(info.error ?? null);
|
|
918
|
+
}
|
|
919
|
+
if (info.clientModel !== void 0) {
|
|
920
|
+
setters.push("client_model = ?");
|
|
921
|
+
values.push(info.clientModel ?? null);
|
|
922
|
+
}
|
|
923
|
+
if (setters.length === 0)
|
|
924
|
+
return;
|
|
925
|
+
const stmt = db2.prepare(`UPDATE request_logs SET ${setters.join(", ")} WHERE id = ?`);
|
|
926
|
+
stmt.run(...values, requestId);
|
|
927
|
+
}
|
|
928
|
+
function upsertLogPayload(requestId, payload) {
|
|
929
|
+
if (payload.prompt === void 0 && payload.response === void 0) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const db2 = getDb();
|
|
933
|
+
const promptData = payload.prompt === void 0 ? null : compressPayload(payload.prompt);
|
|
934
|
+
const responseData = payload.response === void 0 ? null : compressPayload(payload.response);
|
|
935
|
+
db2.prepare(`
|
|
936
|
+
INSERT INTO request_payloads (request_id, prompt, response)
|
|
937
|
+
VALUES (?, ?, ?)
|
|
938
|
+
ON CONFLICT(request_id) DO UPDATE SET
|
|
939
|
+
prompt = COALESCE(excluded.prompt, request_payloads.prompt),
|
|
940
|
+
response = COALESCE(excluded.response, request_payloads.response)
|
|
941
|
+
`).run(
|
|
942
|
+
requestId,
|
|
943
|
+
promptData,
|
|
944
|
+
responseData
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
function updateMetrics(date, delta) {
|
|
948
|
+
const db2 = getDb();
|
|
949
|
+
db2.prepare(`
|
|
950
|
+
INSERT INTO daily_metrics (date, request_count, total_input_tokens, total_output_tokens, total_latency_ms)
|
|
951
|
+
VALUES (@date, @requests, @inputTokens, @outputTokens, @latencyMs)
|
|
952
|
+
ON CONFLICT(date) DO UPDATE SET
|
|
953
|
+
request_count = daily_metrics.request_count + excluded.request_count,
|
|
954
|
+
total_input_tokens = daily_metrics.total_input_tokens + excluded.total_input_tokens,
|
|
955
|
+
total_output_tokens = daily_metrics.total_output_tokens + excluded.total_output_tokens,
|
|
956
|
+
total_latency_ms = daily_metrics.total_latency_ms + excluded.total_latency_ms
|
|
957
|
+
`).run({
|
|
958
|
+
date,
|
|
959
|
+
requests: delta.requests,
|
|
960
|
+
inputTokens: delta.inputTokens,
|
|
961
|
+
outputTokens: delta.outputTokens,
|
|
962
|
+
latencyMs: delta.latencyMs
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// metrics/activity.ts
|
|
967
|
+
var activeRequests = 0;
|
|
968
|
+
function incrementActiveRequests() {
|
|
969
|
+
activeRequests += 1;
|
|
970
|
+
}
|
|
971
|
+
function decrementActiveRequests() {
|
|
972
|
+
if (activeRequests > 0) {
|
|
973
|
+
activeRequests -= 1;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
function getActiveRequestCount() {
|
|
977
|
+
return activeRequests;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// routes/messages.ts
|
|
981
|
+
function mapStopReason(reason) {
|
|
982
|
+
switch (reason) {
|
|
983
|
+
case "stop":
|
|
984
|
+
return "end_turn";
|
|
985
|
+
case "tool_calls":
|
|
986
|
+
return "tool_use";
|
|
987
|
+
case "length":
|
|
988
|
+
return "max_tokens";
|
|
989
|
+
default:
|
|
990
|
+
return reason ?? null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
var roundTwoDecimals = (value) => Math.round(value * 100) / 100;
|
|
994
|
+
function computeTpot(totalLatencyMs, outputTokens, options) {
|
|
995
|
+
if (!Number.isFinite(outputTokens) || outputTokens <= 0) {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
const streaming = options?.streaming ?? false;
|
|
999
|
+
const ttftMs = options?.ttftMs ?? null;
|
|
1000
|
+
if (streaming && (ttftMs === null || ttftMs === void 0)) {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
const effectiveLatency = streaming && ttftMs != null ? Math.max(totalLatencyMs - ttftMs, 0) : totalLatencyMs;
|
|
1004
|
+
const raw = effectiveLatency / outputTokens;
|
|
1005
|
+
return Number.isFinite(raw) ? roundTwoDecimals(raw) : null;
|
|
1006
|
+
}
|
|
1007
|
+
function resolveCachedTokens(usage) {
|
|
1008
|
+
if (!usage || typeof usage !== "object") {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
if (typeof usage.cached_tokens === "number") {
|
|
1012
|
+
return usage.cached_tokens;
|
|
1013
|
+
}
|
|
1014
|
+
const promptDetails = usage.prompt_tokens_details;
|
|
1015
|
+
if (promptDetails && typeof promptDetails.cached_tokens === "number") {
|
|
1016
|
+
return promptDetails.cached_tokens;
|
|
1017
|
+
}
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
function buildClaudeResponse(openAI, model) {
|
|
1021
|
+
const choice = openAI.choices?.[0];
|
|
1022
|
+
const message = choice?.message ?? {};
|
|
1023
|
+
const contentBlocks = [];
|
|
1024
|
+
if (typeof message.content === "string" && message.content.length > 0) {
|
|
1025
|
+
contentBlocks.push({ type: "text", text: message.content });
|
|
1026
|
+
}
|
|
1027
|
+
if (Array.isArray(message.tool_calls)) {
|
|
1028
|
+
for (const call of message.tool_calls) {
|
|
1029
|
+
contentBlocks.push({
|
|
1030
|
+
type: "tool_use",
|
|
1031
|
+
id: call.id || `tool_${Math.random().toString(36).slice(2)}`,
|
|
1032
|
+
name: call.function?.name,
|
|
1033
|
+
input: (() => {
|
|
1034
|
+
try {
|
|
1035
|
+
return call.function?.arguments ? JSON.parse(call.function.arguments) : {};
|
|
1036
|
+
} catch {
|
|
1037
|
+
return {};
|
|
1038
|
+
}
|
|
1039
|
+
})()
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return {
|
|
1044
|
+
id: openAI.id ? openAI.id.replace("chatcmpl", "msg") : `msg_${Math.random().toString(36).slice(2)}`,
|
|
1045
|
+
type: "message",
|
|
1046
|
+
role: "assistant",
|
|
1047
|
+
model,
|
|
1048
|
+
content: contentBlocks,
|
|
1049
|
+
stop_reason: mapStopReason(choice?.finish_reason),
|
|
1050
|
+
stop_sequence: null,
|
|
1051
|
+
usage: {
|
|
1052
|
+
input_tokens: openAI.usage?.prompt_tokens ?? 0,
|
|
1053
|
+
output_tokens: openAI.usage?.completion_tokens ?? 0
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
async function registerMessagesRoute(app) {
|
|
1058
|
+
app.post("/v1/messages", async (request, reply) => {
|
|
1059
|
+
const payload = request.body;
|
|
1060
|
+
if (!payload || typeof payload !== "object") {
|
|
1061
|
+
reply.code(400);
|
|
1062
|
+
return { error: "Invalid request body" };
|
|
1063
|
+
}
|
|
1064
|
+
const normalized = normalizeClaudePayload(payload);
|
|
1065
|
+
const requestedModel = typeof payload.model === "string" ? payload.model : void 0;
|
|
1066
|
+
const target = resolveRoute({
|
|
1067
|
+
payload: normalized,
|
|
1068
|
+
requestedModel
|
|
1069
|
+
});
|
|
1070
|
+
const providerType = target.provider.type ?? "custom";
|
|
1071
|
+
const providerBody = providerType === "anthropic" ? buildAnthropicBody(normalized, {
|
|
1072
|
+
maxTokens: payload.max_tokens ?? target.provider.models?.find((m) => m.id === target.modelId)?.maxTokens,
|
|
1073
|
+
temperature: payload.temperature,
|
|
1074
|
+
toolChoice: payload.tool_choice,
|
|
1075
|
+
overrideTools: payload.tools
|
|
1076
|
+
}) : buildProviderBody(normalized, {
|
|
1077
|
+
maxTokens: payload.max_tokens ?? target.provider.models?.find((m) => m.id === target.modelId)?.maxTokens,
|
|
1078
|
+
temperature: payload.temperature,
|
|
1079
|
+
toolChoice: payload.tool_choice,
|
|
1080
|
+
overrideTools: payload.tools
|
|
1081
|
+
});
|
|
1082
|
+
const connector = getConnector(target.providerId);
|
|
1083
|
+
const requestStart = Date.now();
|
|
1084
|
+
const storePayloads = getConfig().storePayloads !== false;
|
|
1085
|
+
const logId = recordLog({
|
|
1086
|
+
timestamp: requestStart,
|
|
1087
|
+
provider: target.providerId,
|
|
1088
|
+
model: target.modelId,
|
|
1089
|
+
clientModel: requestedModel,
|
|
1090
|
+
sessionId: payload.metadata?.user_id
|
|
1091
|
+
});
|
|
1092
|
+
incrementActiveRequests();
|
|
1093
|
+
if (storePayloads) {
|
|
1094
|
+
upsertLogPayload(logId, {
|
|
1095
|
+
prompt: (() => {
|
|
1096
|
+
try {
|
|
1097
|
+
return JSON.stringify(payload);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
})()
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
let finalized = false;
|
|
1105
|
+
const finalize = (statusCode, error) => {
|
|
1106
|
+
if (finalized)
|
|
1107
|
+
return;
|
|
1108
|
+
finalizeLog(logId, {
|
|
1109
|
+
latencyMs: Date.now() - requestStart,
|
|
1110
|
+
statusCode,
|
|
1111
|
+
error,
|
|
1112
|
+
clientModel: requestedModel ?? null
|
|
1113
|
+
});
|
|
1114
|
+
finalized = true;
|
|
1115
|
+
};
|
|
1116
|
+
const logUsage = (stage, usage) => {
|
|
1117
|
+
request.log.info(
|
|
1118
|
+
{
|
|
1119
|
+
event: "usage.metrics",
|
|
1120
|
+
stage,
|
|
1121
|
+
provider: target.providerId,
|
|
1122
|
+
model: target.modelId,
|
|
1123
|
+
stream: normalized.stream,
|
|
1124
|
+
tokens: usage
|
|
1125
|
+
},
|
|
1126
|
+
"upstream usage summary"
|
|
1127
|
+
);
|
|
1128
|
+
console.info("[cc-gw][usage]", stage, {
|
|
1129
|
+
provider: target.providerId,
|
|
1130
|
+
model: target.modelId,
|
|
1131
|
+
stream: normalized.stream,
|
|
1132
|
+
tokens: usage
|
|
1133
|
+
});
|
|
1134
|
+
};
|
|
1135
|
+
try {
|
|
1136
|
+
const upstream = await connector.send({
|
|
1137
|
+
model: target.modelId,
|
|
1138
|
+
body: providerBody,
|
|
1139
|
+
stream: normalized.stream
|
|
1140
|
+
});
|
|
1141
|
+
if (upstream.status >= 400) {
|
|
1142
|
+
reply.code(upstream.status);
|
|
1143
|
+
const bodyText = upstream.body ? await new Response(upstream.body).text() : "";
|
|
1144
|
+
const errorText = bodyText || "Upstream provider error";
|
|
1145
|
+
if (storePayloads) {
|
|
1146
|
+
upsertLogPayload(logId, { response: bodyText || null });
|
|
1147
|
+
}
|
|
1148
|
+
finalize(upstream.status, errorText);
|
|
1149
|
+
return { error: errorText };
|
|
1150
|
+
}
|
|
1151
|
+
if (!normalized.stream) {
|
|
1152
|
+
const json = await new Response(upstream.body).json();
|
|
1153
|
+
if (providerType === "anthropic") {
|
|
1154
|
+
let inputTokens2 = json.usage?.input_tokens ?? 0;
|
|
1155
|
+
let outputTokens2 = json.usage?.output_tokens ?? 0;
|
|
1156
|
+
const cachedTokens2 = resolveCachedTokens(json.usage);
|
|
1157
|
+
if (!inputTokens2) {
|
|
1158
|
+
inputTokens2 = target.tokenEstimate || estimateTokens(normalized, target.modelId);
|
|
1159
|
+
}
|
|
1160
|
+
if (!outputTokens2) {
|
|
1161
|
+
const textBlocks = Array.isArray(json.content) ? json.content.filter((block) => block?.type === "text").map((block) => block.text ?? "").join("\n") : "";
|
|
1162
|
+
outputTokens2 = estimateTextTokens(textBlocks, target.modelId);
|
|
1163
|
+
}
|
|
1164
|
+
logUsage("non_stream.anthropic", {
|
|
1165
|
+
input: inputTokens2,
|
|
1166
|
+
output: outputTokens2,
|
|
1167
|
+
cached: cachedTokens2
|
|
1168
|
+
});
|
|
1169
|
+
const latencyMs2 = Date.now() - requestStart;
|
|
1170
|
+
updateLogTokens(logId, {
|
|
1171
|
+
inputTokens: inputTokens2,
|
|
1172
|
+
outputTokens: outputTokens2,
|
|
1173
|
+
cachedTokens: cachedTokens2,
|
|
1174
|
+
ttftMs: latencyMs2,
|
|
1175
|
+
tpotMs: computeTpot(latencyMs2, outputTokens2, { streaming: false })
|
|
1176
|
+
});
|
|
1177
|
+
updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
|
|
1178
|
+
requests: 1,
|
|
1179
|
+
inputTokens: inputTokens2,
|
|
1180
|
+
outputTokens: outputTokens2,
|
|
1181
|
+
latencyMs: latencyMs2
|
|
1182
|
+
});
|
|
1183
|
+
if (storePayloads) {
|
|
1184
|
+
upsertLogPayload(logId, {
|
|
1185
|
+
response: (() => {
|
|
1186
|
+
try {
|
|
1187
|
+
return JSON.stringify(json);
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
})()
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
finalize(200, null);
|
|
1195
|
+
reply.header("content-type", "application/json");
|
|
1196
|
+
return json;
|
|
1197
|
+
}
|
|
1198
|
+
const claudeResponse = buildClaudeResponse(json, target.modelId);
|
|
1199
|
+
let inputTokens = json.usage?.prompt_tokens ?? 0;
|
|
1200
|
+
let outputTokens = json.usage?.completion_tokens ?? 0;
|
|
1201
|
+
const cachedTokens = resolveCachedTokens(json.usage);
|
|
1202
|
+
if (!inputTokens) {
|
|
1203
|
+
inputTokens = target.tokenEstimate || estimateTokens(normalized, target.modelId);
|
|
1204
|
+
}
|
|
1205
|
+
if (!outputTokens) {
|
|
1206
|
+
const text = claudeResponse.content.filter((block) => block?.type === "text").map((block) => block.text ?? "").join("\n");
|
|
1207
|
+
outputTokens = estimateTextTokens(text, target.modelId);
|
|
1208
|
+
}
|
|
1209
|
+
logUsage("non_stream.openai", {
|
|
1210
|
+
input: inputTokens,
|
|
1211
|
+
output: outputTokens,
|
|
1212
|
+
cached: cachedTokens
|
|
1213
|
+
});
|
|
1214
|
+
const latencyMs = Date.now() - requestStart;
|
|
1215
|
+
updateLogTokens(logId, {
|
|
1216
|
+
inputTokens,
|
|
1217
|
+
outputTokens,
|
|
1218
|
+
cachedTokens,
|
|
1219
|
+
ttftMs: latencyMs,
|
|
1220
|
+
tpotMs: computeTpot(latencyMs, outputTokens, { streaming: false })
|
|
1221
|
+
});
|
|
1222
|
+
updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
|
|
1223
|
+
requests: 1,
|
|
1224
|
+
inputTokens,
|
|
1225
|
+
outputTokens,
|
|
1226
|
+
latencyMs
|
|
1227
|
+
});
|
|
1228
|
+
if (storePayloads) {
|
|
1229
|
+
upsertLogPayload(logId, {
|
|
1230
|
+
response: (() => {
|
|
1231
|
+
try {
|
|
1232
|
+
return JSON.stringify(claudeResponse);
|
|
1233
|
+
} catch {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
})()
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
finalize(200, null);
|
|
1240
|
+
reply.header("content-type", "application/json");
|
|
1241
|
+
return claudeResponse;
|
|
1242
|
+
}
|
|
1243
|
+
if (!upstream.body) {
|
|
1244
|
+
reply.code(500);
|
|
1245
|
+
finalize(500, "Upstream returned empty body");
|
|
1246
|
+
return { error: "Upstream returned empty body" };
|
|
1247
|
+
}
|
|
1248
|
+
reply.header("content-type", "text/event-stream; charset=utf-8");
|
|
1249
|
+
reply.header("cache-control", "no-cache, no-store, must-revalidate");
|
|
1250
|
+
reply.header("connection", "keep-alive");
|
|
1251
|
+
reply.raw.writeHead(200);
|
|
1252
|
+
if (providerType === "anthropic") {
|
|
1253
|
+
const reader2 = upstream.body.getReader();
|
|
1254
|
+
const decoder2 = new TextDecoder();
|
|
1255
|
+
let buffer2 = "";
|
|
1256
|
+
let currentEvent = null;
|
|
1257
|
+
let usagePrompt2 = 0;
|
|
1258
|
+
let usageCompletion2 = 0;
|
|
1259
|
+
let usageCached2 = null;
|
|
1260
|
+
let accumulatedContent2 = "";
|
|
1261
|
+
while (true) {
|
|
1262
|
+
const { value, done } = await reader2.read();
|
|
1263
|
+
if (done)
|
|
1264
|
+
break;
|
|
1265
|
+
if (!value)
|
|
1266
|
+
continue;
|
|
1267
|
+
const chunk = decoder2.decode(value, { stream: true });
|
|
1268
|
+
buffer2 += chunk;
|
|
1269
|
+
let newlineIndex = buffer2.indexOf("\n");
|
|
1270
|
+
while (newlineIndex !== -1) {
|
|
1271
|
+
const line = buffer2.slice(0, newlineIndex + 1);
|
|
1272
|
+
buffer2 = buffer2.slice(newlineIndex + 1);
|
|
1273
|
+
const trimmed = line.trim();
|
|
1274
|
+
if (trimmed.startsWith("event:")) {
|
|
1275
|
+
currentEvent = trimmed.slice(6).trim();
|
|
1276
|
+
} else if (trimmed.startsWith("data:")) {
|
|
1277
|
+
if (currentEvent === "message_delta" || currentEvent === "message_stop") {
|
|
1278
|
+
try {
|
|
1279
|
+
const data = JSON.parse(trimmed.slice(5).trim());
|
|
1280
|
+
if (data?.usage) {
|
|
1281
|
+
usagePrompt2 = data.usage.input_tokens ?? usagePrompt2;
|
|
1282
|
+
usageCompletion2 = data.usage.output_tokens ?? usageCompletion2;
|
|
1283
|
+
if (typeof data.usage.cached_tokens === "number") {
|
|
1284
|
+
usageCached2 = data.usage.cached_tokens;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
const deltaText = data?.delta?.text;
|
|
1288
|
+
if (typeof deltaText === "string") {
|
|
1289
|
+
accumulatedContent2 += deltaText;
|
|
1290
|
+
}
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
request.log.warn({ error }, "Failed to parse Anthropic SSE data");
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
reply.raw.write(line);
|
|
1297
|
+
newlineIndex = buffer2.indexOf("\n");
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (buffer2.length > 0) {
|
|
1301
|
+
reply.raw.write(buffer2);
|
|
1302
|
+
}
|
|
1303
|
+
reply.raw.end();
|
|
1304
|
+
if (!usagePrompt2) {
|
|
1305
|
+
usagePrompt2 = target.tokenEstimate || estimateTokens(normalized, target.modelId);
|
|
1306
|
+
}
|
|
1307
|
+
if (!usageCompletion2) {
|
|
1308
|
+
usageCompletion2 = accumulatedContent2 ? estimateTextTokens(accumulatedContent2, target.modelId) : estimateTextTokens("", target.modelId);
|
|
1309
|
+
}
|
|
1310
|
+
const totalLatencyMs = Date.now() - requestStart;
|
|
1311
|
+
const ttftMs = firstTokenAt ? firstTokenAt - requestStart : null;
|
|
1312
|
+
logUsage("stream.anthropic.final", {
|
|
1313
|
+
input: usagePrompt2,
|
|
1314
|
+
output: usageCompletion2,
|
|
1315
|
+
cached: usageCached2
|
|
1316
|
+
});
|
|
1317
|
+
updateLogTokens(logId, {
|
|
1318
|
+
inputTokens: usagePrompt2,
|
|
1319
|
+
outputTokens: usageCompletion2,
|
|
1320
|
+
cachedTokens: usageCached2,
|
|
1321
|
+
ttftMs,
|
|
1322
|
+
tpotMs: computeTpot(totalLatencyMs, usageCompletion2, {
|
|
1323
|
+
streaming: true,
|
|
1324
|
+
ttftMs
|
|
1325
|
+
})
|
|
1326
|
+
});
|
|
1327
|
+
updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
|
|
1328
|
+
requests: 1,
|
|
1329
|
+
inputTokens: usagePrompt2,
|
|
1330
|
+
outputTokens: usageCompletion2,
|
|
1331
|
+
latencyMs: totalLatencyMs
|
|
1332
|
+
});
|
|
1333
|
+
if (storePayloads) {
|
|
1334
|
+
upsertLogPayload(logId, {
|
|
1335
|
+
response: (() => {
|
|
1336
|
+
try {
|
|
1337
|
+
return JSON.stringify({
|
|
1338
|
+
content: accumulatedContent2,
|
|
1339
|
+
usage: {
|
|
1340
|
+
input: usagePrompt2,
|
|
1341
|
+
output: usageCompletion2,
|
|
1342
|
+
cached: usageCached2
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
} catch {
|
|
1346
|
+
return accumulatedContent2;
|
|
1347
|
+
}
|
|
1348
|
+
})()
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
finalize(200, null);
|
|
1352
|
+
return reply;
|
|
1353
|
+
}
|
|
1354
|
+
const reader = upstream.body.getReader();
|
|
1355
|
+
const decoder = new TextDecoder();
|
|
1356
|
+
let buffer = "";
|
|
1357
|
+
let textBlockStarted = false;
|
|
1358
|
+
let encounteredToolCall = false;
|
|
1359
|
+
const toolAccum = {};
|
|
1360
|
+
let usagePrompt = 0;
|
|
1361
|
+
let usageCompletion = 0;
|
|
1362
|
+
let usageCached = null;
|
|
1363
|
+
let accumulatedContent = "";
|
|
1364
|
+
let completed = false;
|
|
1365
|
+
let firstTokenAt = null;
|
|
1366
|
+
const encode = (event, data) => {
|
|
1367
|
+
reply.raw.write(`event: ${event}
|
|
1368
|
+
data: ${JSON.stringify(data)}
|
|
1369
|
+
|
|
1370
|
+
`);
|
|
1371
|
+
};
|
|
1372
|
+
encode("message_start", {
|
|
1373
|
+
type: "message_start",
|
|
1374
|
+
message: {
|
|
1375
|
+
id: `msg_${Math.random().toString(36).slice(2)}`,
|
|
1376
|
+
type: "message",
|
|
1377
|
+
role: "assistant",
|
|
1378
|
+
model: target.modelId,
|
|
1379
|
+
content: [],
|
|
1380
|
+
stop_reason: null,
|
|
1381
|
+
stop_sequence: null,
|
|
1382
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
while (true) {
|
|
1386
|
+
const { value, done } = await reader.read();
|
|
1387
|
+
if (done)
|
|
1388
|
+
break;
|
|
1389
|
+
if (!value)
|
|
1390
|
+
continue;
|
|
1391
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1392
|
+
buffer += chunk;
|
|
1393
|
+
const lines = buffer.split("\n");
|
|
1394
|
+
if (!buffer.endsWith("\n")) {
|
|
1395
|
+
buffer = lines.pop() ?? "";
|
|
1396
|
+
} else {
|
|
1397
|
+
buffer = "";
|
|
1398
|
+
}
|
|
1399
|
+
for (const line of lines) {
|
|
1400
|
+
const trimmed = line.trim();
|
|
1401
|
+
if (!trimmed.startsWith("data:"))
|
|
1402
|
+
continue;
|
|
1403
|
+
const dataStr = trimmed.slice(5).trim();
|
|
1404
|
+
if (dataStr === "[DONE]") {
|
|
1405
|
+
if (encounteredToolCall) {
|
|
1406
|
+
for (const idx of Object.keys(toolAccum)) {
|
|
1407
|
+
encode("content_block_stop", {
|
|
1408
|
+
type: "content_block_stop",
|
|
1409
|
+
index: Number(idx)
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
} else if (textBlockStarted) {
|
|
1413
|
+
encode("content_block_stop", {
|
|
1414
|
+
type: "content_block_stop",
|
|
1415
|
+
index: 0
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
const finalPromptTokens = usagePrompt || target.tokenEstimate || estimateTokens(normalized, target.modelId);
|
|
1419
|
+
const finalCompletionTokens = usageCompletion || estimateTextTokens(accumulatedContent, target.modelId);
|
|
1420
|
+
encode("message_delta", {
|
|
1421
|
+
type: "message_delta",
|
|
1422
|
+
delta: {
|
|
1423
|
+
stop_reason: encounteredToolCall ? "tool_use" : "end_turn",
|
|
1424
|
+
stop_sequence: null
|
|
1425
|
+
},
|
|
1426
|
+
usage: {
|
|
1427
|
+
input_tokens: finalPromptTokens,
|
|
1428
|
+
output_tokens: finalCompletionTokens
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
encode("message_stop", { type: "message_stop" });
|
|
1432
|
+
reply.raw.write("\n");
|
|
1433
|
+
reply.raw.end();
|
|
1434
|
+
const totalLatencyMs = Date.now() - requestStart;
|
|
1435
|
+
const ttftMs = firstTokenAt ? firstTokenAt - requestStart : null;
|
|
1436
|
+
logUsage("stream.openai.final", {
|
|
1437
|
+
input: finalPromptTokens,
|
|
1438
|
+
output: finalCompletionTokens,
|
|
1439
|
+
cached: usageCached
|
|
1440
|
+
});
|
|
1441
|
+
updateLogTokens(logId, {
|
|
1442
|
+
inputTokens: finalPromptTokens,
|
|
1443
|
+
outputTokens: finalCompletionTokens,
|
|
1444
|
+
cachedTokens: usageCached,
|
|
1445
|
+
ttftMs,
|
|
1446
|
+
tpotMs: computeTpot(totalLatencyMs, finalCompletionTokens, {
|
|
1447
|
+
streaming: true,
|
|
1448
|
+
ttftMs
|
|
1449
|
+
})
|
|
1450
|
+
});
|
|
1451
|
+
updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
|
|
1452
|
+
requests: 1,
|
|
1453
|
+
inputTokens: finalPromptTokens,
|
|
1454
|
+
outputTokens: finalCompletionTokens,
|
|
1455
|
+
latencyMs: totalLatencyMs
|
|
1456
|
+
});
|
|
1457
|
+
if (storePayloads) {
|
|
1458
|
+
upsertLogPayload(logId, {
|
|
1459
|
+
response: (() => {
|
|
1460
|
+
try {
|
|
1461
|
+
return JSON.stringify({
|
|
1462
|
+
content: accumulatedContent,
|
|
1463
|
+
toolCalls: Object.keys(toolAccum).length > 0 ? toolAccum : void 0,
|
|
1464
|
+
usage: {
|
|
1465
|
+
input: finalPromptTokens,
|
|
1466
|
+
output: finalCompletionTokens,
|
|
1467
|
+
cached: usageCached
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
} catch {
|
|
1471
|
+
return accumulatedContent;
|
|
1472
|
+
}
|
|
1473
|
+
})()
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
finalize(200, null);
|
|
1477
|
+
completed = true;
|
|
1478
|
+
return reply;
|
|
1479
|
+
}
|
|
1480
|
+
let parsed;
|
|
1481
|
+
try {
|
|
1482
|
+
parsed = JSON.parse(dataStr);
|
|
1483
|
+
} catch {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const choice = parsed.choices?.[0];
|
|
1487
|
+
if (!choice)
|
|
1488
|
+
continue;
|
|
1489
|
+
const usagePayload = parsed.usage || choice.usage || choice.delta && choice.delta.usage || null;
|
|
1490
|
+
if (usagePayload) {
|
|
1491
|
+
usagePrompt = usagePayload.prompt_tokens ?? usagePrompt;
|
|
1492
|
+
usageCompletion = usagePayload.completion_tokens ?? usageCompletion;
|
|
1493
|
+
if (typeof usagePayload.cached_tokens === "number") {
|
|
1494
|
+
usageCached = usagePayload.cached_tokens;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (choice.delta?.tool_calls) {
|
|
1498
|
+
if (!firstTokenAt) {
|
|
1499
|
+
firstTokenAt = Date.now();
|
|
1500
|
+
}
|
|
1501
|
+
encounteredToolCall = true;
|
|
1502
|
+
for (const toolCall of choice.delta.tool_calls) {
|
|
1503
|
+
const idx = toolCall.index ?? 0;
|
|
1504
|
+
if (toolAccum[idx] === void 0) {
|
|
1505
|
+
toolAccum[idx] = "";
|
|
1506
|
+
encode("content_block_start", {
|
|
1507
|
+
type: "content_block_start",
|
|
1508
|
+
index: idx,
|
|
1509
|
+
content_block: {
|
|
1510
|
+
type: "tool_use",
|
|
1511
|
+
id: toolCall.id || `tool_${Date.now()}_${idx}`,
|
|
1512
|
+
name: toolCall.function?.name,
|
|
1513
|
+
input: {}
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
const deltaArgs = toolCall.function?.arguments || "";
|
|
1518
|
+
if (deltaArgs) {
|
|
1519
|
+
toolAccum[idx] += deltaArgs;
|
|
1520
|
+
encode("content_block_delta", {
|
|
1521
|
+
type: "content_block_delta",
|
|
1522
|
+
index: idx,
|
|
1523
|
+
delta: {
|
|
1524
|
+
type: "input_json_delta",
|
|
1525
|
+
partial_json: deltaArgs
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
if (choice.delta?.content) {
|
|
1533
|
+
if (!firstTokenAt && choice.delta.content.length > 0) {
|
|
1534
|
+
firstTokenAt = Date.now();
|
|
1535
|
+
}
|
|
1536
|
+
if (!textBlockStarted) {
|
|
1537
|
+
textBlockStarted = true;
|
|
1538
|
+
encode("content_block_start", {
|
|
1539
|
+
type: "content_block_start",
|
|
1540
|
+
index: 0,
|
|
1541
|
+
content_block: {
|
|
1542
|
+
type: "text",
|
|
1543
|
+
text: ""
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
encode("content_block_delta", {
|
|
1548
|
+
type: "content_block_delta",
|
|
1549
|
+
index: 0,
|
|
1550
|
+
delta: {
|
|
1551
|
+
type: "text_delta",
|
|
1552
|
+
text: choice.delta.content
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
accumulatedContent += choice.delta.content ?? "";
|
|
1556
|
+
}
|
|
1557
|
+
if (choice.delta?.reasoning) {
|
|
1558
|
+
if (!firstTokenAt) {
|
|
1559
|
+
firstTokenAt = Date.now();
|
|
1560
|
+
}
|
|
1561
|
+
if (!textBlockStarted) {
|
|
1562
|
+
textBlockStarted = true;
|
|
1563
|
+
encode("content_block_start", {
|
|
1564
|
+
type: "content_block_start",
|
|
1565
|
+
index: 0,
|
|
1566
|
+
content_block: {
|
|
1567
|
+
type: "text",
|
|
1568
|
+
text: ""
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
encode("content_block_delta", {
|
|
1573
|
+
type: "content_block_delta",
|
|
1574
|
+
index: 0,
|
|
1575
|
+
delta: {
|
|
1576
|
+
type: "thinking_delta",
|
|
1577
|
+
thinking: choice.delta.reasoning
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (!completed) {
|
|
1584
|
+
reply.raw.end();
|
|
1585
|
+
const totalLatencyMs = Date.now() - requestStart;
|
|
1586
|
+
const fallbackPrompt = usagePrompt || target.tokenEstimate || estimateTokens(normalized, target.modelId);
|
|
1587
|
+
const fallbackCompletion = usageCompletion || estimateTextTokens(accumulatedContent, target.modelId);
|
|
1588
|
+
const ttftMs = firstTokenAt ? firstTokenAt - requestStart : null;
|
|
1589
|
+
logUsage("stream.openai.fallback", {
|
|
1590
|
+
input: fallbackPrompt,
|
|
1591
|
+
output: fallbackCompletion,
|
|
1592
|
+
cached: usageCached
|
|
1593
|
+
});
|
|
1594
|
+
updateLogTokens(logId, {
|
|
1595
|
+
inputTokens: fallbackPrompt,
|
|
1596
|
+
outputTokens: fallbackCompletion,
|
|
1597
|
+
cachedTokens: usageCached,
|
|
1598
|
+
ttftMs,
|
|
1599
|
+
tpotMs: computeTpot(totalLatencyMs, fallbackCompletion, {
|
|
1600
|
+
streaming: true,
|
|
1601
|
+
ttftMs
|
|
1602
|
+
})
|
|
1603
|
+
});
|
|
1604
|
+
updateMetrics((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), {
|
|
1605
|
+
requests: 1,
|
|
1606
|
+
inputTokens: fallbackPrompt,
|
|
1607
|
+
outputTokens: fallbackCompletion,
|
|
1608
|
+
latencyMs: totalLatencyMs
|
|
1609
|
+
});
|
|
1610
|
+
if (storePayloads) {
|
|
1611
|
+
upsertLogPayload(logId, {
|
|
1612
|
+
response: (() => {
|
|
1613
|
+
try {
|
|
1614
|
+
return JSON.stringify({
|
|
1615
|
+
content: accumulatedContent,
|
|
1616
|
+
usage: {
|
|
1617
|
+
input: fallbackPrompt,
|
|
1618
|
+
output: fallbackCompletion,
|
|
1619
|
+
cached: usageCached
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
} catch {
|
|
1623
|
+
return accumulatedContent;
|
|
1624
|
+
}
|
|
1625
|
+
})()
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
finalize(200, null);
|
|
1629
|
+
return reply;
|
|
1630
|
+
}
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
const message = err instanceof Error ? err.message : "Unexpected error";
|
|
1633
|
+
if (!reply.sent) {
|
|
1634
|
+
reply.code(500);
|
|
1635
|
+
}
|
|
1636
|
+
finalize(reply.statusCode >= 400 ? reply.statusCode : 500, message);
|
|
1637
|
+
return { error: message };
|
|
1638
|
+
} finally {
|
|
1639
|
+
decrementActiveRequests();
|
|
1640
|
+
if (!finalized && reply.sent) {
|
|
1641
|
+
finalize(reply.statusCode ?? 200, null);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// logging/queries.ts
|
|
1648
|
+
function queryLogs(options = {}) {
|
|
1649
|
+
const db2 = getDb();
|
|
1650
|
+
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
|
|
1651
|
+
const offset = Math.max(options.offset ?? 0, 0);
|
|
1652
|
+
const conditions = [];
|
|
1653
|
+
const params = {};
|
|
1654
|
+
if (options.provider) {
|
|
1655
|
+
conditions.push("provider = @provider");
|
|
1656
|
+
params.provider = options.provider;
|
|
1657
|
+
}
|
|
1658
|
+
if (options.model) {
|
|
1659
|
+
conditions.push("model = @model");
|
|
1660
|
+
params.model = options.model;
|
|
1661
|
+
}
|
|
1662
|
+
if (options.status === "success") {
|
|
1663
|
+
conditions.push("error IS NULL");
|
|
1664
|
+
} else if (options.status === "error") {
|
|
1665
|
+
conditions.push("error IS NOT NULL");
|
|
1666
|
+
}
|
|
1667
|
+
if (typeof options.from === "number") {
|
|
1668
|
+
conditions.push("timestamp >= @from");
|
|
1669
|
+
params.from = options.from;
|
|
1670
|
+
}
|
|
1671
|
+
if (typeof options.to === "number") {
|
|
1672
|
+
conditions.push("timestamp <= @to");
|
|
1673
|
+
params.to = options.to;
|
|
1674
|
+
}
|
|
1675
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1676
|
+
const totalRow = db2.prepare(`SELECT COUNT(*) AS count FROM request_logs ${whereClause}`).get(params);
|
|
1677
|
+
const items = db2.prepare(
|
|
1678
|
+
`SELECT id, timestamp, session_id, provider, model, client_model, latency_ms, status_code, input_tokens, output_tokens, cached_tokens, ttft_ms, tpot_ms, error
|
|
1679
|
+
FROM request_logs
|
|
1680
|
+
${whereClause}
|
|
1681
|
+
ORDER BY timestamp DESC
|
|
1682
|
+
LIMIT @limit OFFSET @offset`
|
|
1683
|
+
).all({ ...params, limit, offset });
|
|
1684
|
+
return {
|
|
1685
|
+
total: totalRow?.count ?? 0,
|
|
1686
|
+
items
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
function getLogDetail(id) {
|
|
1690
|
+
const db2 = getDb();
|
|
1691
|
+
const record = db2.prepare(
|
|
1692
|
+
`SELECT id, timestamp, session_id, provider, model, client_model, latency_ms, status_code, input_tokens, output_tokens, cached_tokens, ttft_ms, tpot_ms, error
|
|
1693
|
+
FROM request_logs
|
|
1694
|
+
WHERE id = ?`
|
|
1695
|
+
).get(id);
|
|
1696
|
+
return record ?? null;
|
|
1697
|
+
}
|
|
1698
|
+
function getLogPayload(id) {
|
|
1699
|
+
const db2 = getDb();
|
|
1700
|
+
const payload = db2.prepare(`SELECT prompt, response FROM request_payloads WHERE request_id = ?`).get(id);
|
|
1701
|
+
if (!payload) {
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
prompt: decompressPayload(payload.prompt),
|
|
1706
|
+
response: decompressPayload(payload.response)
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
function cleanupLogsBefore(timestamp) {
|
|
1710
|
+
const db2 = getDb();
|
|
1711
|
+
const stmt = db2.prepare(`DELETE FROM request_logs WHERE timestamp < ?`);
|
|
1712
|
+
const result = stmt.run(timestamp);
|
|
1713
|
+
return Number(result.changes ?? 0);
|
|
1714
|
+
}
|
|
1715
|
+
function getDailyMetrics(days = 7) {
|
|
1716
|
+
const db2 = getDb();
|
|
1717
|
+
const rows = db2.prepare(
|
|
1718
|
+
`SELECT date, request_count AS requestCount, total_input_tokens AS inputTokens,
|
|
1719
|
+
total_output_tokens AS outputTokens, total_latency_ms AS totalLatency
|
|
1720
|
+
FROM daily_metrics
|
|
1721
|
+
ORDER BY date DESC
|
|
1722
|
+
LIMIT ?`
|
|
1723
|
+
).all(days);
|
|
1724
|
+
return rows.map((row) => ({
|
|
1725
|
+
date: row.date,
|
|
1726
|
+
requestCount: row.requestCount ?? 0,
|
|
1727
|
+
inputTokens: row.inputTokens ?? 0,
|
|
1728
|
+
outputTokens: row.outputTokens ?? 0,
|
|
1729
|
+
avgLatencyMs: row.requestCount ? Math.round((row.totalLatency ?? 0) / row.requestCount) : 0
|
|
1730
|
+
})).reverse();
|
|
1731
|
+
}
|
|
1732
|
+
function getMetricsOverview() {
|
|
1733
|
+
const db2 = getDb();
|
|
1734
|
+
const totalsRow = db2.prepare(
|
|
1735
|
+
`SELECT
|
|
1736
|
+
COALESCE(SUM(request_count), 0) AS requests,
|
|
1737
|
+
COALESCE(SUM(total_input_tokens), 0) AS inputTokens,
|
|
1738
|
+
COALESCE(SUM(total_output_tokens), 0) AS outputTokens,
|
|
1739
|
+
COALESCE(SUM(total_latency_ms), 0) AS totalLatency
|
|
1740
|
+
FROM daily_metrics`
|
|
1741
|
+
).get();
|
|
1742
|
+
const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1743
|
+
const todayRow = db2.prepare(
|
|
1744
|
+
`SELECT request_count AS requests,
|
|
1745
|
+
total_input_tokens AS inputTokens,
|
|
1746
|
+
total_output_tokens AS outputTokens,
|
|
1747
|
+
total_latency_ms AS totalLatency
|
|
1748
|
+
FROM daily_metrics WHERE date = ?`
|
|
1749
|
+
).get(todayKey);
|
|
1750
|
+
const resolveAvg = (totalLatency, requests) => requests > 0 ? Math.round(totalLatency / requests) : 0;
|
|
1751
|
+
const totalsRequests = totalsRow.requests ?? 0;
|
|
1752
|
+
const totalsLatency = totalsRow.totalLatency ?? 0;
|
|
1753
|
+
const todayRequests = todayRow?.requests ?? 0;
|
|
1754
|
+
const todayLatency = todayRow?.totalLatency ?? 0;
|
|
1755
|
+
return {
|
|
1756
|
+
totals: {
|
|
1757
|
+
requests: totalsRequests,
|
|
1758
|
+
inputTokens: totalsRow.inputTokens ?? 0,
|
|
1759
|
+
outputTokens: totalsRow.outputTokens ?? 0,
|
|
1760
|
+
avgLatencyMs: resolveAvg(totalsLatency, totalsRequests)
|
|
1761
|
+
},
|
|
1762
|
+
today: {
|
|
1763
|
+
requests: todayRequests,
|
|
1764
|
+
inputTokens: todayRow?.inputTokens ?? 0,
|
|
1765
|
+
outputTokens: todayRow?.outputTokens ?? 0,
|
|
1766
|
+
avgLatencyMs: resolveAvg(todayLatency, todayRequests)
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
function getModelUsageMetrics(days = 7, limit = 10) {
|
|
1771
|
+
const db2 = getDb();
|
|
1772
|
+
const since = Date.now() - days * 24 * 60 * 60 * 1e3;
|
|
1773
|
+
const rows = db2.prepare(
|
|
1774
|
+
`SELECT
|
|
1775
|
+
model,
|
|
1776
|
+
provider,
|
|
1777
|
+
COUNT(*) AS requests,
|
|
1778
|
+
COALESCE(SUM(input_tokens), 0) AS inputTokens,
|
|
1779
|
+
COALESCE(SUM(output_tokens), 0) AS outputTokens,
|
|
1780
|
+
COALESCE(SUM(latency_ms), 0) AS totalLatency
|
|
1781
|
+
FROM request_logs
|
|
1782
|
+
WHERE timestamp >= ?
|
|
1783
|
+
GROUP BY provider, model
|
|
1784
|
+
ORDER BY requests DESC
|
|
1785
|
+
LIMIT ?`
|
|
1786
|
+
).all(since, limit);
|
|
1787
|
+
return rows.map((row) => ({
|
|
1788
|
+
model: row.model,
|
|
1789
|
+
provider: row.provider,
|
|
1790
|
+
requests: row.requests ?? 0,
|
|
1791
|
+
inputTokens: row.inputTokens ?? 0,
|
|
1792
|
+
outputTokens: row.outputTokens ?? 0,
|
|
1793
|
+
avgLatencyMs: row.requests ? Math.round((row.totalLatency ?? 0) / row.requests) : 0
|
|
1794
|
+
}));
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// routes/admin.ts
|
|
1798
|
+
async function registerAdminRoutes(app) {
|
|
1799
|
+
app.get("/api/status", async () => {
|
|
1800
|
+
const config = getConfig();
|
|
1801
|
+
return {
|
|
1802
|
+
port: config.port,
|
|
1803
|
+
host: config.host,
|
|
1804
|
+
providers: config.providers.length,
|
|
1805
|
+
activeRequests: getActiveRequestCount()
|
|
1806
|
+
};
|
|
1807
|
+
});
|
|
1808
|
+
app.get("/api/providers", async () => {
|
|
1809
|
+
const config = getConfig();
|
|
1810
|
+
return config.providers;
|
|
1811
|
+
});
|
|
1812
|
+
app.get("/api/config", async () => {
|
|
1813
|
+
return getConfig();
|
|
1814
|
+
});
|
|
1815
|
+
app.get("/api/config/info", async () => {
|
|
1816
|
+
const config = getConfig();
|
|
1817
|
+
return {
|
|
1818
|
+
config,
|
|
1819
|
+
path: CONFIG_PATH
|
|
1820
|
+
};
|
|
1821
|
+
});
|
|
1822
|
+
app.put("/api/config", async (request, reply) => {
|
|
1823
|
+
const body = request.body;
|
|
1824
|
+
if (!body || typeof body.port !== "number") {
|
|
1825
|
+
reply.code(400);
|
|
1826
|
+
return { error: "Invalid config payload" };
|
|
1827
|
+
}
|
|
1828
|
+
updateConfig(body);
|
|
1829
|
+
return { success: true };
|
|
1830
|
+
});
|
|
1831
|
+
app.post("/api/providers/:id/test", async (request, reply) => {
|
|
1832
|
+
const id = String(request.params.id);
|
|
1833
|
+
const config = getConfig();
|
|
1834
|
+
const provider = config.providers.find((item) => item.id === id);
|
|
1835
|
+
if (!provider) {
|
|
1836
|
+
reply.code(404);
|
|
1837
|
+
return { error: "Provider not found" };
|
|
1838
|
+
}
|
|
1839
|
+
const startedAt = Date.now();
|
|
1840
|
+
const targetModel = provider.defaultModel || provider.models?.[0]?.id;
|
|
1841
|
+
if (!targetModel) {
|
|
1842
|
+
reply.code(400);
|
|
1843
|
+
return {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
status: 0,
|
|
1846
|
+
statusText: "No model configured for provider"
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
const testPayload = normalizeClaudePayload({
|
|
1850
|
+
model: targetModel,
|
|
1851
|
+
stream: false,
|
|
1852
|
+
temperature: 0,
|
|
1853
|
+
messages: [
|
|
1854
|
+
{
|
|
1855
|
+
role: "user",
|
|
1856
|
+
content: [
|
|
1857
|
+
{
|
|
1858
|
+
type: "text",
|
|
1859
|
+
text: "\u4F60\u597D\uFF0C\u8FD9\u662F\u4E00\u6B21\u8FDE\u63A5\u6D4B\u8BD5\u3002\u8BF7\u7B80\u77ED\u56DE\u5E94\u4EE5\u786E\u8BA4\u670D\u52A1\u53EF\u7528\u3002"
|
|
1860
|
+
}
|
|
1861
|
+
]
|
|
1862
|
+
}
|
|
1863
|
+
],
|
|
1864
|
+
system: "You are a connection diagnostic assistant."
|
|
1865
|
+
});
|
|
1866
|
+
const providerBody = provider.type === "anthropic" ? buildAnthropicBody(testPayload, {
|
|
1867
|
+
maxTokens: provider.models?.find((m) => m.id === targetModel)?.maxTokens ?? 256,
|
|
1868
|
+
temperature: 0,
|
|
1869
|
+
toolChoice: void 0,
|
|
1870
|
+
overrideTools: void 0
|
|
1871
|
+
}) : buildProviderBody(testPayload, {
|
|
1872
|
+
maxTokens: provider.models?.find((m) => m.id === targetModel)?.maxTokens ?? 256,
|
|
1873
|
+
temperature: 0,
|
|
1874
|
+
toolChoice: void 0,
|
|
1875
|
+
overrideTools: void 0
|
|
1876
|
+
});
|
|
1877
|
+
const connector = getConnector(provider.id);
|
|
1878
|
+
try {
|
|
1879
|
+
const upstream = await connector.send({
|
|
1880
|
+
model: targetModel,
|
|
1881
|
+
body: providerBody,
|
|
1882
|
+
stream: false
|
|
1883
|
+
});
|
|
1884
|
+
const duration = Date.now() - startedAt;
|
|
1885
|
+
if (upstream.status >= 400) {
|
|
1886
|
+
const errorText = upstream.body ? await new Response(upstream.body).text() : "";
|
|
1887
|
+
return {
|
|
1888
|
+
ok: false,
|
|
1889
|
+
status: upstream.status,
|
|
1890
|
+
statusText: errorText || "Upstream error",
|
|
1891
|
+
durationMs: duration
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
const raw = upstream.body ? await new Response(upstream.body).text() : "";
|
|
1895
|
+
let parsed = null;
|
|
1896
|
+
try {
|
|
1897
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
1898
|
+
} catch {
|
|
1899
|
+
return {
|
|
1900
|
+
ok: false,
|
|
1901
|
+
status: upstream.status,
|
|
1902
|
+
statusText: "Invalid JSON response",
|
|
1903
|
+
durationMs: duration
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
let sample = "";
|
|
1907
|
+
if (provider.type === "anthropic") {
|
|
1908
|
+
const contentBlocks = Array.isArray(parsed?.content) ? parsed.content : [];
|
|
1909
|
+
const textBlocks = contentBlocks.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
|
|
1910
|
+
sample = textBlocks.join("\n");
|
|
1911
|
+
} else {
|
|
1912
|
+
const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : null;
|
|
1913
|
+
if (choice) {
|
|
1914
|
+
if (Array.isArray(choice.message?.content)) {
|
|
1915
|
+
sample = choice.message.content.join("\n");
|
|
1916
|
+
} else {
|
|
1917
|
+
sample = choice.message?.content ?? choice.text ?? "";
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
sample = typeof sample === "string" ? sample.trim() : "";
|
|
1922
|
+
return {
|
|
1923
|
+
ok: Boolean(sample),
|
|
1924
|
+
status: upstream.status,
|
|
1925
|
+
statusText: sample ? "OK" : "Empty response",
|
|
1926
|
+
durationMs: duration,
|
|
1927
|
+
sample: sample ? sample.slice(0, 200) : null
|
|
1928
|
+
};
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
reply.code(502);
|
|
1931
|
+
return {
|
|
1932
|
+
ok: false,
|
|
1933
|
+
status: 0,
|
|
1934
|
+
statusText: error instanceof Error ? error.message : "Network error",
|
|
1935
|
+
durationMs: Date.now() - startedAt
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
app.get("/api/logs", async (request, reply) => {
|
|
1940
|
+
const query = request.query ?? {};
|
|
1941
|
+
const limitRaw = Number(query.limit ?? 50);
|
|
1942
|
+
const offsetRaw = Number(query.offset ?? 0);
|
|
1943
|
+
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50;
|
|
1944
|
+
const offset = Number.isFinite(offsetRaw) ? Math.max(offsetRaw, 0) : 0;
|
|
1945
|
+
const provider = typeof query.provider === "string" && query.provider.length > 0 ? query.provider : void 0;
|
|
1946
|
+
const model = typeof query.model === "string" && query.model.length > 0 ? query.model : void 0;
|
|
1947
|
+
const statusParam = typeof query.status === "string" ? query.status : void 0;
|
|
1948
|
+
const status = statusParam === "success" || statusParam === "error" ? statusParam : void 0;
|
|
1949
|
+
const parseTime = (value) => {
|
|
1950
|
+
if (!value)
|
|
1951
|
+
return void 0;
|
|
1952
|
+
const numeric = Number(value);
|
|
1953
|
+
if (Number.isFinite(numeric))
|
|
1954
|
+
return numeric;
|
|
1955
|
+
const parsed = Date.parse(value);
|
|
1956
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1957
|
+
};
|
|
1958
|
+
const from = parseTime(query.from);
|
|
1959
|
+
const to = parseTime(query.to);
|
|
1960
|
+
const { items, total } = queryLogs({ limit, offset, provider, model, status, from, to });
|
|
1961
|
+
reply.header("x-total-count", String(total));
|
|
1962
|
+
return { total, items };
|
|
1963
|
+
});
|
|
1964
|
+
app.get("/api/logs/:id", async (request, reply) => {
|
|
1965
|
+
const id = Number(request.params.id);
|
|
1966
|
+
if (!Number.isFinite(id)) {
|
|
1967
|
+
reply.code(400);
|
|
1968
|
+
return { error: "Invalid id" };
|
|
1969
|
+
}
|
|
1970
|
+
const record = getLogDetail(id);
|
|
1971
|
+
if (!record) {
|
|
1972
|
+
reply.code(404);
|
|
1973
|
+
return { error: "Not found" };
|
|
1974
|
+
}
|
|
1975
|
+
const payload = getLogPayload(id);
|
|
1976
|
+
return { ...record, payload };
|
|
1977
|
+
});
|
|
1978
|
+
app.post("/api/logs/cleanup", async () => {
|
|
1979
|
+
const config = getConfig();
|
|
1980
|
+
const retentionDays = config.logRetentionDays ?? 30;
|
|
1981
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
1982
|
+
const deleted = cleanupLogsBefore(cutoff);
|
|
1983
|
+
return { success: true, deleted };
|
|
1984
|
+
});
|
|
1985
|
+
app.get("/api/db/info", async () => {
|
|
1986
|
+
const db2 = getDb();
|
|
1987
|
+
const pageCount = db2.pragma("page_count", { simple: true });
|
|
1988
|
+
const pageSize = db2.pragma("page_size", { simple: true });
|
|
1989
|
+
return {
|
|
1990
|
+
pageCount,
|
|
1991
|
+
pageSize,
|
|
1992
|
+
sizeBytes: pageCount * pageSize
|
|
1993
|
+
};
|
|
1994
|
+
});
|
|
1995
|
+
app.get("/api/stats/overview", async () => {
|
|
1996
|
+
return getMetricsOverview();
|
|
1997
|
+
});
|
|
1998
|
+
app.get("/api/stats/daily", async (request) => {
|
|
1999
|
+
const query = request.query ?? {};
|
|
2000
|
+
const daysRaw = Number(query.days ?? 7);
|
|
2001
|
+
const days = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 30)) : 7;
|
|
2002
|
+
return getDailyMetrics(days);
|
|
2003
|
+
});
|
|
2004
|
+
app.get("/api/stats/model", async (request) => {
|
|
2005
|
+
const query = request.query ?? {};
|
|
2006
|
+
const daysRaw = Number(query.days ?? 7);
|
|
2007
|
+
const limitRaw = Number(query.limit ?? 10);
|
|
2008
|
+
const days = Number.isFinite(daysRaw) ? Math.max(1, Math.min(daysRaw, 90)) : 7;
|
|
2009
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(limitRaw, 50)) : 10;
|
|
2010
|
+
return getModelUsageMetrics(days, limit);
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// tasks/maintenance.ts
|
|
2015
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2016
|
+
var timersStarted = false;
|
|
2017
|
+
function startMaintenanceTimers() {
|
|
2018
|
+
if (timersStarted)
|
|
2019
|
+
return;
|
|
2020
|
+
timersStarted = true;
|
|
2021
|
+
scheduleCleanup();
|
|
2022
|
+
}
|
|
2023
|
+
function scheduleCleanup() {
|
|
2024
|
+
const run = () => {
|
|
2025
|
+
try {
|
|
2026
|
+
const retentionDays = getConfig().logRetentionDays ?? 30;
|
|
2027
|
+
const cutoff = Date.now() - retentionDays * DAY_MS;
|
|
2028
|
+
const deleted = cleanupLogsBefore(cutoff);
|
|
2029
|
+
if (deleted > 0) {
|
|
2030
|
+
console.info(`[maintenance] cleaned ${deleted} old log entries`);
|
|
2031
|
+
}
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
console.error("[maintenance] cleanup failed", err);
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
setInterval(run, DAY_MS);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// index.ts
|
|
2040
|
+
var DEFAULT_PORT = 3456;
|
|
2041
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
2042
|
+
var cachedConfig2 = loadConfig();
|
|
2043
|
+
onConfigChange((config) => {
|
|
2044
|
+
cachedConfig2 = config;
|
|
2045
|
+
});
|
|
2046
|
+
function resolveWebDist() {
|
|
2047
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
2048
|
+
const __dirname = path3.dirname(__filename2);
|
|
2049
|
+
const candidates = [
|
|
2050
|
+
process2.env.CC_GW_UI_ROOT,
|
|
2051
|
+
path3.resolve(__dirname, "../web/public"),
|
|
2052
|
+
path3.resolve(__dirname, "../web/dist"),
|
|
2053
|
+
path3.resolve(__dirname, "../../web/dist"),
|
|
2054
|
+
path3.resolve(__dirname, "../../../src/web/dist"),
|
|
2055
|
+
path3.resolve(process2.cwd(), "src/web/dist")
|
|
2056
|
+
].filter((item) => Boolean(item));
|
|
2057
|
+
for (const candidate of candidates) {
|
|
2058
|
+
if (fs3.existsSync(candidate)) {
|
|
2059
|
+
return candidate;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return null;
|
|
2063
|
+
}
|
|
2064
|
+
async function createServer() {
|
|
2065
|
+
const app = Fastify({ logger: true });
|
|
2066
|
+
await app.register(fastifyCors, {
|
|
2067
|
+
origin: true,
|
|
2068
|
+
credentials: true
|
|
2069
|
+
});
|
|
2070
|
+
const webRoot = resolveWebDist();
|
|
2071
|
+
if (webRoot) {
|
|
2072
|
+
await app.register(fastifyStatic, {
|
|
2073
|
+
root: webRoot,
|
|
2074
|
+
prefix: "/ui/"
|
|
2075
|
+
});
|
|
2076
|
+
app.get("/", async (_, reply) => reply.redirect("/ui/"));
|
|
2077
|
+
app.get("/ui", async (_, reply) => reply.redirect("/ui/"));
|
|
2078
|
+
const assetHandler = async (request, reply) => {
|
|
2079
|
+
const params = request.params;
|
|
2080
|
+
const target = params["*"] ?? "";
|
|
2081
|
+
if (target.includes("..")) {
|
|
2082
|
+
reply.code(400);
|
|
2083
|
+
return { error: "Invalid asset path" };
|
|
2084
|
+
}
|
|
2085
|
+
return reply.sendFile(path3.join("assets", target));
|
|
2086
|
+
};
|
|
2087
|
+
app.get("/assets/*", assetHandler);
|
|
2088
|
+
app.head("/assets/*", assetHandler);
|
|
2089
|
+
const faviconHandler = async (_, reply) => reply.sendFile("favicon.ico");
|
|
2090
|
+
app.get("/favicon.ico", faviconHandler);
|
|
2091
|
+
app.head("/favicon.ico", faviconHandler);
|
|
2092
|
+
app.setNotFoundHandler((request, reply) => {
|
|
2093
|
+
const url = request.raw.url ?? "";
|
|
2094
|
+
if (url.startsWith("/ui/")) {
|
|
2095
|
+
reply.type("text/html");
|
|
2096
|
+
return reply.sendFile("index.html");
|
|
2097
|
+
}
|
|
2098
|
+
reply.code(404).send({ error: "Not Found" });
|
|
2099
|
+
});
|
|
2100
|
+
} else {
|
|
2101
|
+
app.log.warn("\u672A\u627E\u5230 Web UI \u6784\u5EFA\u4EA7\u7269\uFF0C/ui \u76EE\u5F55\u5C06\u4E0D\u53EF\u7528\u3002");
|
|
2102
|
+
}
|
|
2103
|
+
await registerMessagesRoute(app);
|
|
2104
|
+
await registerAdminRoutes(app);
|
|
2105
|
+
startMaintenanceTimers();
|
|
2106
|
+
app.get("/health", async () => {
|
|
2107
|
+
return {
|
|
2108
|
+
status: "ok",
|
|
2109
|
+
timestamp: Date.now(),
|
|
2110
|
+
providerCount: getConfig().providers.length
|
|
2111
|
+
};
|
|
2112
|
+
});
|
|
2113
|
+
return app;
|
|
2114
|
+
}
|
|
2115
|
+
async function startServer(options = {}) {
|
|
2116
|
+
const app = await createServer();
|
|
2117
|
+
const envPort = process2.env.PORT ? Number.parseInt(process2.env.PORT, 10) : void 0;
|
|
2118
|
+
const envHost = process2.env.HOST;
|
|
2119
|
+
const configPort = cachedConfig2?.port;
|
|
2120
|
+
const configHost = cachedConfig2?.host;
|
|
2121
|
+
const port = options.port ?? envPort ?? configPort ?? DEFAULT_PORT;
|
|
2122
|
+
const host = options.host ?? envHost ?? configHost ?? DEFAULT_HOST;
|
|
2123
|
+
await app.listen({ port, host });
|
|
2124
|
+
return app;
|
|
2125
|
+
}
|
|
2126
|
+
async function main() {
|
|
2127
|
+
try {
|
|
2128
|
+
const app = await startServer();
|
|
2129
|
+
const shutdown = async () => {
|
|
2130
|
+
try {
|
|
2131
|
+
await app.close();
|
|
2132
|
+
process2.exit(0);
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
app.log.error({ err }, "\u5173\u95ED\u670D\u52A1\u5931\u8D25");
|
|
2135
|
+
process2.exit(1);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
process2.on("SIGTERM", shutdown);
|
|
2139
|
+
process2.on("SIGINT", shutdown);
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
console.error("\u542F\u52A8\u670D\u52A1\u5931\u8D25", err);
|
|
2142
|
+
process2.exit(1);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
2146
|
+
if (process2.argv[1] && path3.resolve(process2.argv[1]) === __filename) {
|
|
2147
|
+
main();
|
|
2148
|
+
}
|
|
2149
|
+
export {
|
|
2150
|
+
createServer,
|
|
2151
|
+
startServer
|
|
2152
|
+
};
|