llm-simple-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/admin/constants.d.ts +10 -0
- package/dist/admin/constants.js +11 -0
- package/dist/admin/groups.d.ts +7 -0
- package/dist/admin/groups.js +118 -0
- package/dist/admin/logs.d.ts +7 -0
- package/dist/admin/logs.js +43 -0
- package/dist/admin/mappings.d.ts +7 -0
- package/dist/admin/mappings.js +120 -0
- package/dist/admin/metrics.d.ts +7 -0
- package/dist/admin/metrics.js +41 -0
- package/dist/admin/providers.d.ts +8 -0
- package/dist/admin/providers.js +101 -0
- package/dist/admin/retry-rules.d.ts +9 -0
- package/dist/admin/retry-rules.js +98 -0
- package/dist/admin/router-keys.d.ts +8 -0
- package/dist/admin/router-keys.js +85 -0
- package/dist/admin/routes.d.ts +12 -0
- package/dist/admin/routes.js +22 -0
- package/dist/admin/services.d.ts +7 -0
- package/dist/admin/services.js +63 -0
- package/dist/admin/stats.d.ts +7 -0
- package/dist/admin/stats.js +15 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +28 -0
- package/dist/db/helpers.d.ts +12 -0
- package/dist/db/helpers.js +28 -0
- package/dist/db/index.d.ts +16 -0
- package/dist/db/index.js +45 -0
- package/dist/db/logs.d.ts +90 -0
- package/dist/db/logs.js +47 -0
- package/dist/db/mappings.d.ts +36 -0
- package/dist/db/mappings.js +55 -0
- package/dist/db/metrics.d.ts +24 -0
- package/dist/db/metrics.js +119 -0
- package/dist/db/migrations/001_init.sql +37 -0
- package/dist/db/migrations/002_add_request_response_body.sql +2 -0
- package/dist/db/migrations/003_add_full_request_chain_log.sql +4 -0
- package/dist/db/migrations/004_rename_to_providers.sql +9 -0
- package/dist/db/migrations/005_add_api_key_preview.sql +1 -0
- package/dist/db/migrations/006_create_request_metrics.sql +20 -0
- package/dist/db/migrations/007_add_retry_fields.sql +2 -0
- package/dist/db/migrations/008_create_router_keys.sql +17 -0
- package/dist/db/migrations/009_add_request_logs_indexes.sql +2 -0
- package/dist/db/migrations/010_add_key_encrypted.sql +1 -0
- package/dist/db/migrations/011_create_mapping_groups.sql +33 -0
- package/dist/db/migrations/012_add_provider_models.sql +2 -0
- package/dist/db/migrations/013_add_retry_strategy.sql +4 -0
- package/dist/db/providers.d.ts +27 -0
- package/dist/db/providers.js +29 -0
- package/dist/db/retry-rules.d.ts +32 -0
- package/dist/db/retry-rules.js +49 -0
- package/dist/db/router-keys.d.ts +29 -0
- package/dist/db/router-keys.js +36 -0
- package/dist/db/stats.d.ts +9 -0
- package/dist/db/stats.js +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +131 -0
- package/dist/metrics/metrics-extractor.d.ts +32 -0
- package/dist/metrics/metrics-extractor.js +178 -0
- package/dist/metrics/sse-metrics-transform.d.ts +16 -0
- package/dist/metrics/sse-metrics-transform.js +35 -0
- package/dist/metrics/sse-parser.d.ts +20 -0
- package/dist/metrics/sse-parser.js +81 -0
- package/dist/middleware/admin-auth.d.ts +8 -0
- package/dist/middleware/admin-auth.js +57 -0
- package/dist/middleware/auth.d.ts +14 -0
- package/dist/middleware/auth.js +41 -0
- package/dist/proxy/anthropic.d.ts +12 -0
- package/dist/proxy/anthropic.js +34 -0
- package/dist/proxy/mapping-resolver.d.ts +3 -0
- package/dist/proxy/mapping-resolver.js +27 -0
- package/dist/proxy/openai.d.ts +12 -0
- package/dist/proxy/openai.js +72 -0
- package/dist/proxy/proxy-core.d.ts +75 -0
- package/dist/proxy/proxy-core.js +408 -0
- package/dist/proxy/retry-rules.d.ts +9 -0
- package/dist/proxy/retry-rules.js +27 -0
- package/dist/proxy/retry.d.ts +43 -0
- package/dist/proxy/retry.js +120 -0
- package/dist/proxy/strategy/failover.d.ts +4 -0
- package/dist/proxy/strategy/failover.js +5 -0
- package/dist/proxy/strategy/random.d.ts +4 -0
- package/dist/proxy/strategy/random.js +5 -0
- package/dist/proxy/strategy/round-robin.d.ts +4 -0
- package/dist/proxy/strategy/round-robin.js +5 -0
- package/dist/proxy/strategy/scheduled.d.ts +4 -0
- package/dist/proxy/strategy/scheduled.js +62 -0
- package/dist/proxy/strategy/types.d.ts +13 -0
- package/dist/proxy/strategy/types.js +3 -0
- package/dist/utils/crypto.d.ts +2 -0
- package/dist/utils/crypto.js +32 -0
- package/frontend-dist/assets/CardContent-BE9fukPi.js +1 -0
- package/frontend-dist/assets/CardHeader-D5lVaeAA.js +1 -0
- package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +1 -0
- package/frontend-dist/assets/Checkbox--1gw0dYW.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +1 -0
- package/frontend-dist/assets/Dashboard-D4AwkULO.js +3 -0
- package/frontend-dist/assets/Label-GiPfoz7u.js +1 -0
- package/frontend-dist/assets/Login-BUet1sbM.js +1 -0
- package/frontend-dist/assets/Logs-yztb_F9t.js +3 -0
- package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +1 -0
- package/frontend-dist/assets/Providers-BjsqH6A2.js +1 -0
- package/frontend-dist/assets/RetryRules-C2vvJvLr.js +1 -0
- package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +1 -0
- package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +1 -0
- package/frontend-dist/assets/TableHeader-D2GkiqRx.js +1 -0
- package/frontend-dist/assets/alert-dialog-CWjBke-O.js +1 -0
- package/frontend-dist/assets/badge-_ZHrMEpC.js +3 -0
- package/frontend-dist/assets/button-C4_mChkc.js +1 -0
- package/frontend-dist/assets/client-BWw0R36V.js +12 -0
- package/frontend-dist/assets/dialog-CUHMcTqp.js +1 -0
- package/frontend-dist/assets/index-DEl48bm9.css +1 -0
- package/frontend-dist/assets/index-UZK1BnPG.js +1 -0
- package/frontend-dist/assets/lib-Qs8xoTas.js +1 -0
- package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +1 -0
- package/frontend-dist/assets/x-JBJB26JV.js +1 -0
- package/frontend-dist/favicon.svg +1 -0
- package/frontend-dist/icons.svg +24 -0
- package/frontend-dist/index.html +18 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import Fastify from "fastify";
|
|
6
|
+
const HTTP_NOT_FOUND = 404;
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
import { getConfig } from "./config.js";
|
|
10
|
+
import { initDatabase, seedDefaultRules } from "./db/index.js";
|
|
11
|
+
import { authMiddleware } from "./middleware/auth.js";
|
|
12
|
+
import { openaiProxy } from "./proxy/openai.js";
|
|
13
|
+
import { anthropicProxy } from "./proxy/anthropic.js";
|
|
14
|
+
import { adminRoutes } from "./admin/routes.js";
|
|
15
|
+
import { RetryRuleMatcher } from "./proxy/retry-rules.js";
|
|
16
|
+
import fastifyStatic from "@fastify/static";
|
|
17
|
+
export async function buildApp(options) {
|
|
18
|
+
const config = options?.config ?? getConfig();
|
|
19
|
+
// 允许外部传入已初始化的 DB(测试用),否则自行创建
|
|
20
|
+
let db;
|
|
21
|
+
if (options?.db) {
|
|
22
|
+
db = options.db;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
db = initDatabase(config.DB_PATH);
|
|
26
|
+
}
|
|
27
|
+
const app = Fastify({
|
|
28
|
+
logger: {
|
|
29
|
+
level: config.LOG_LEVEL,
|
|
30
|
+
},
|
|
31
|
+
// 统一 schema validation 错误格式为 { error: { message } }
|
|
32
|
+
ajv: {
|
|
33
|
+
customOptions: {
|
|
34
|
+
messages: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
app.setSchemaErrorFormatter((errors) => {
|
|
39
|
+
const message = errors
|
|
40
|
+
.map((e) => {
|
|
41
|
+
const field = e.instancePath ? e.instancePath.slice(1) : e.params?.missingProperty ?? "field";
|
|
42
|
+
return `${field} ${e.message}`;
|
|
43
|
+
})
|
|
44
|
+
.join("; ");
|
|
45
|
+
return new Error(message);
|
|
46
|
+
});
|
|
47
|
+
// 统一 schema validation 错误响应格式
|
|
48
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
49
|
+
const fastifyError = error;
|
|
50
|
+
const status = fastifyError.statusCode ?? 500;
|
|
51
|
+
if (status === 400 && fastifyError.validation) {
|
|
52
|
+
return reply.code(400).send({ error: { message: fastifyError.message } });
|
|
53
|
+
}
|
|
54
|
+
return reply.code(status).send({ error: { message: fastifyError.message } });
|
|
55
|
+
});
|
|
56
|
+
// 首次启动时插入默认重试规则(表为空时)
|
|
57
|
+
seedDefaultRules(db);
|
|
58
|
+
const matcher = new RetryRuleMatcher();
|
|
59
|
+
matcher.load(db);
|
|
60
|
+
app.register(authMiddleware, { db });
|
|
61
|
+
app.register(openaiProxy, {
|
|
62
|
+
db,
|
|
63
|
+
encryptionKey: config.ENCRYPTION_KEY,
|
|
64
|
+
streamTimeoutMs: config.STREAM_TIMEOUT_MS,
|
|
65
|
+
retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
|
|
66
|
+
retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
|
|
67
|
+
matcher,
|
|
68
|
+
});
|
|
69
|
+
app.register(anthropicProxy, {
|
|
70
|
+
db,
|
|
71
|
+
encryptionKey: config.ENCRYPTION_KEY,
|
|
72
|
+
streamTimeoutMs: config.STREAM_TIMEOUT_MS,
|
|
73
|
+
retryMaxAttempts: config.RETRY_MAX_ATTEMPTS,
|
|
74
|
+
retryBaseDelayMs: config.RETRY_BASE_DELAY_MS,
|
|
75
|
+
matcher,
|
|
76
|
+
});
|
|
77
|
+
app.register(adminRoutes, {
|
|
78
|
+
db,
|
|
79
|
+
adminPassword: config.ADMIN_PASSWORD,
|
|
80
|
+
jwtSecret: config.JWT_SECRET,
|
|
81
|
+
encryptionKey: config.ENCRYPTION_KEY,
|
|
82
|
+
matcher,
|
|
83
|
+
});
|
|
84
|
+
// 前端静态文件服务(生产环境)
|
|
85
|
+
const frontendDist = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../frontend-dist"));
|
|
86
|
+
if (existsSync(frontendDist)) {
|
|
87
|
+
app.register(fastifyStatic, {
|
|
88
|
+
root: frontendDist,
|
|
89
|
+
prefix: "/admin/",
|
|
90
|
+
wildcard: false,
|
|
91
|
+
});
|
|
92
|
+
// SPA fallback: /admin/ 下非 API 路径返回 index.html
|
|
93
|
+
app.setNotFoundHandler((request, reply) => {
|
|
94
|
+
if (request.url.startsWith("/admin") &&
|
|
95
|
+
!request.url.startsWith("/admin/api")) {
|
|
96
|
+
return reply.sendFile("index.html");
|
|
97
|
+
}
|
|
98
|
+
reply.code(HTTP_NOT_FOUND).send({ error: "Not Found" });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
app.log.warn(`Frontend dist not found at ${frontendDist}, skipping static serving`);
|
|
103
|
+
}
|
|
104
|
+
app.get("/health", async () => {
|
|
105
|
+
return { status: "ok" };
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
app,
|
|
109
|
+
db,
|
|
110
|
+
close: async () => {
|
|
111
|
+
await app.close();
|
|
112
|
+
db.close();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function main() {
|
|
117
|
+
const { app } = await buildApp();
|
|
118
|
+
const config = getConfig();
|
|
119
|
+
try {
|
|
120
|
+
await app.listen({ port: config.PORT, host: "0.0.0.0" });
|
|
121
|
+
app.log.info(`Server listening on port ${config.PORT}`);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
app.log.error(err);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const isMainModule = process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts");
|
|
129
|
+
if (isMainModule) {
|
|
130
|
+
main();
|
|
131
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SSEEvent } from "./sse-parser.js";
|
|
2
|
+
export interface MetricsResult {
|
|
3
|
+
input_tokens: number | null;
|
|
4
|
+
output_tokens: number | null;
|
|
5
|
+
cache_creation_tokens: number | null;
|
|
6
|
+
cache_read_tokens: number | null;
|
|
7
|
+
ttft_ms: number | null;
|
|
8
|
+
total_duration_ms: number | null;
|
|
9
|
+
tokens_per_second: number | null;
|
|
10
|
+
stop_reason: string | null;
|
|
11
|
+
is_complete: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class MetricsExtractor {
|
|
14
|
+
private apiType;
|
|
15
|
+
private requestStartTime;
|
|
16
|
+
private inputTokens;
|
|
17
|
+
private outputTokens;
|
|
18
|
+
private cacheCreationTokens;
|
|
19
|
+
private cacheReadTokens;
|
|
20
|
+
private ttftMs;
|
|
21
|
+
private streamStartTime;
|
|
22
|
+
private streamEndTime;
|
|
23
|
+
private stopReason;
|
|
24
|
+
private firstContentReceived;
|
|
25
|
+
private complete;
|
|
26
|
+
constructor(apiType: "openai" | "anthropic", requestStartTime: number);
|
|
27
|
+
processEvent(event: SSEEvent): void;
|
|
28
|
+
getMetrics(): MetricsResult;
|
|
29
|
+
static fromNonStreamResponse(apiType: "openai" | "anthropic", responseBody: string): MetricsResult | null;
|
|
30
|
+
private processAnthropicEvent;
|
|
31
|
+
private processOpenAIEvent;
|
|
32
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const MS_PER_SECOND = 1000;
|
|
2
|
+
export class MetricsExtractor {
|
|
3
|
+
apiType;
|
|
4
|
+
requestStartTime;
|
|
5
|
+
inputTokens = null;
|
|
6
|
+
outputTokens = null;
|
|
7
|
+
cacheCreationTokens = null;
|
|
8
|
+
cacheReadTokens = null;
|
|
9
|
+
ttftMs = null;
|
|
10
|
+
streamStartTime = null;
|
|
11
|
+
streamEndTime = null;
|
|
12
|
+
stopReason = null;
|
|
13
|
+
firstContentReceived = false;
|
|
14
|
+
complete = false;
|
|
15
|
+
constructor(apiType, requestStartTime) {
|
|
16
|
+
this.apiType = apiType;
|
|
17
|
+
this.requestStartTime = requestStartTime;
|
|
18
|
+
}
|
|
19
|
+
processEvent(event) {
|
|
20
|
+
if (!event.data)
|
|
21
|
+
return;
|
|
22
|
+
if (this.apiType === "anthropic") {
|
|
23
|
+
this.processAnthropicEvent(event);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
this.processOpenAIEvent(event);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
getMetrics() {
|
|
30
|
+
let totalDurationMs = null;
|
|
31
|
+
let tokensPerSecond = null;
|
|
32
|
+
if (this.streamStartTime !== null &&
|
|
33
|
+
this.streamEndTime !== null &&
|
|
34
|
+
this.outputTokens !== null) {
|
|
35
|
+
totalDurationMs = this.streamEndTime - this.streamStartTime;
|
|
36
|
+
if (totalDurationMs > 0) {
|
|
37
|
+
tokensPerSecond =
|
|
38
|
+
this.outputTokens / (totalDurationMs / MS_PER_SECOND);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
input_tokens: this.inputTokens,
|
|
43
|
+
output_tokens: this.outputTokens,
|
|
44
|
+
cache_creation_tokens: this.cacheCreationTokens,
|
|
45
|
+
cache_read_tokens: this.cacheReadTokens,
|
|
46
|
+
ttft_ms: this.ttftMs,
|
|
47
|
+
total_duration_ms: totalDurationMs,
|
|
48
|
+
tokens_per_second: tokensPerSecond,
|
|
49
|
+
stop_reason: this.stopReason,
|
|
50
|
+
is_complete: this.complete ? 1 : 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
static fromNonStreamResponse(apiType, responseBody) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(responseBody);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (apiType === "openai") {
|
|
62
|
+
return extractOpenAINonStream(parsed);
|
|
63
|
+
}
|
|
64
|
+
return extractAnthropicNonStream(parsed);
|
|
65
|
+
}
|
|
66
|
+
processAnthropicEvent(event) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(event.data);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const type = parsed.type;
|
|
75
|
+
if (type === "message_start") {
|
|
76
|
+
const msg = parsed;
|
|
77
|
+
const usage = msg.message?.usage;
|
|
78
|
+
if (usage) {
|
|
79
|
+
this.inputTokens = usage.input_tokens ?? null;
|
|
80
|
+
this.cacheCreationTokens = usage.cache_creation_input_tokens ?? null;
|
|
81
|
+
this.cacheReadTokens = usage.cache_read_input_tokens ?? null;
|
|
82
|
+
}
|
|
83
|
+
this.streamStartTime = Date.now();
|
|
84
|
+
}
|
|
85
|
+
else if (type === "content_block_delta") {
|
|
86
|
+
// 首次收到内容时记录 TTFT(不管是 thinking_delta 还是 text_delta)
|
|
87
|
+
if (!this.firstContentReceived) {
|
|
88
|
+
this.firstContentReceived = true;
|
|
89
|
+
this.ttftMs = Date.now() - this.requestStartTime;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (type === "message_delta") {
|
|
93
|
+
const msg = parsed;
|
|
94
|
+
this.outputTokens = msg.usage?.output_tokens ?? null;
|
|
95
|
+
this.stopReason = msg.delta?.stop_reason ?? null;
|
|
96
|
+
this.streamEndTime = Date.now();
|
|
97
|
+
}
|
|
98
|
+
else if (type === "message_stop") {
|
|
99
|
+
this.complete = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
processOpenAIEvent(event) {
|
|
103
|
+
// SSEParser 通常会拦截 [DONE],但以防直接传入
|
|
104
|
+
if (event.data === "[DONE]") {
|
|
105
|
+
this.complete = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(event.data);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const choices = parsed.choices;
|
|
116
|
+
if (choices && choices.length > 0) {
|
|
117
|
+
const choice = choices[0];
|
|
118
|
+
const delta = choice.delta;
|
|
119
|
+
// 跳过只有 role 的 chunk,不视为内容
|
|
120
|
+
if (!this.firstContentReceived &&
|
|
121
|
+
delta &&
|
|
122
|
+
delta.content !== undefined &&
|
|
123
|
+
delta.content !== "") {
|
|
124
|
+
this.firstContentReceived = true;
|
|
125
|
+
this.ttftMs = Date.now() - this.requestStartTime;
|
|
126
|
+
}
|
|
127
|
+
if (choice.finish_reason) {
|
|
128
|
+
this.stopReason = choice.finish_reason;
|
|
129
|
+
this.streamEndTime = Date.now();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// usage 通常在最后一个 chunk 中
|
|
133
|
+
if (parsed.usage) {
|
|
134
|
+
this.inputTokens = parsed.usage.prompt_tokens ?? null;
|
|
135
|
+
this.outputTokens = parsed.usage.completion_tokens ?? null;
|
|
136
|
+
this.cacheReadTokens =
|
|
137
|
+
parsed.usage.prompt_tokens_details?.cached_tokens ?? null;
|
|
138
|
+
// usage chunk 标志流结束,确保 duration 可计算
|
|
139
|
+
if (this.streamStartTime === null) {
|
|
140
|
+
this.streamStartTime = this.requestStartTime;
|
|
141
|
+
}
|
|
142
|
+
if (this.streamEndTime === null) {
|
|
143
|
+
this.streamEndTime = Date.now();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function extractOpenAINonStream(parsed) {
|
|
149
|
+
const usage = parsed.usage;
|
|
150
|
+
const choices = parsed.choices;
|
|
151
|
+
const stopReason = choices?.[0]?.finish_reason ?? null;
|
|
152
|
+
const details = usage?.prompt_tokens_details;
|
|
153
|
+
return {
|
|
154
|
+
input_tokens: usage?.prompt_tokens ?? null,
|
|
155
|
+
output_tokens: usage?.completion_tokens ?? null,
|
|
156
|
+
cache_creation_tokens: null,
|
|
157
|
+
cache_read_tokens: details?.cached_tokens ?? null,
|
|
158
|
+
ttft_ms: null,
|
|
159
|
+
total_duration_ms: null,
|
|
160
|
+
tokens_per_second: null,
|
|
161
|
+
stop_reason: stopReason,
|
|
162
|
+
is_complete: 1,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function extractAnthropicNonStream(parsed) {
|
|
166
|
+
const usage = parsed.usage;
|
|
167
|
+
return {
|
|
168
|
+
input_tokens: usage?.input_tokens ?? null,
|
|
169
|
+
output_tokens: usage?.output_tokens ?? null,
|
|
170
|
+
cache_creation_tokens: usage?.cache_creation_input_tokens ?? null,
|
|
171
|
+
cache_read_tokens: usage?.cache_read_input_tokens ?? null,
|
|
172
|
+
ttft_ms: null,
|
|
173
|
+
total_duration_ms: null,
|
|
174
|
+
tokens_per_second: null,
|
|
175
|
+
stop_reason: parsed.stop_reason ?? null,
|
|
176
|
+
is_complete: 1,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Transform, TransformCallback } from "stream";
|
|
2
|
+
import { MetricsExtractor } from "./metrics-extractor.js";
|
|
3
|
+
/**
|
|
4
|
+
* 旁路采集 SSE 指标的 Transform stream
|
|
5
|
+
*
|
|
6
|
+
* 管道位置: upstream → SSEMetricsTransform → PassThrough → reply.raw
|
|
7
|
+
* 不修改流经的数据,仅解析 SSE 事件并提取指标。
|
|
8
|
+
*/
|
|
9
|
+
export declare class SSEMetricsTransform extends Transform {
|
|
10
|
+
private parser;
|
|
11
|
+
private extractor;
|
|
12
|
+
constructor(apiType: "openai" | "anthropic", requestStartTime: number);
|
|
13
|
+
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
|
|
14
|
+
_flush(callback: TransformCallback): void;
|
|
15
|
+
getExtractor(): MetricsExtractor;
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Transform } from "stream";
|
|
2
|
+
import { SSEParser } from "./sse-parser.js";
|
|
3
|
+
import { MetricsExtractor } from "./metrics-extractor.js";
|
|
4
|
+
/**
|
|
5
|
+
* 旁路采集 SSE 指标的 Transform stream
|
|
6
|
+
*
|
|
7
|
+
* 管道位置: upstream → SSEMetricsTransform → PassThrough → reply.raw
|
|
8
|
+
* 不修改流经的数据,仅解析 SSE 事件并提取指标。
|
|
9
|
+
*/
|
|
10
|
+
export class SSEMetricsTransform extends Transform {
|
|
11
|
+
parser;
|
|
12
|
+
extractor;
|
|
13
|
+
constructor(apiType, requestStartTime) {
|
|
14
|
+
super();
|
|
15
|
+
this.parser = new SSEParser();
|
|
16
|
+
this.extractor = new MetricsExtractor(apiType, requestStartTime);
|
|
17
|
+
}
|
|
18
|
+
_transform(chunk, _encoding, callback) {
|
|
19
|
+
const events = this.parser.feed(chunk.toString("utf-8"));
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
this.extractor.processEvent(event);
|
|
22
|
+
}
|
|
23
|
+
callback(null, chunk);
|
|
24
|
+
}
|
|
25
|
+
_flush(callback) {
|
|
26
|
+
const events = this.parser.flush();
|
|
27
|
+
for (const event of events) {
|
|
28
|
+
this.extractor.processEvent(event);
|
|
29
|
+
}
|
|
30
|
+
callback();
|
|
31
|
+
}
|
|
32
|
+
getExtractor() {
|
|
33
|
+
return this.extractor;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE 行缓冲解析器
|
|
3
|
+
*
|
|
4
|
+
* 将 TCP 流中分片的文本块按 SSE 协议解析为结构化事件。
|
|
5
|
+
* 不是 Transform stream,而是纯解析类,由上层 Transform 调用。
|
|
6
|
+
*/
|
|
7
|
+
export interface SSEEvent {
|
|
8
|
+
event?: string;
|
|
9
|
+
data?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class SSEParser {
|
|
12
|
+
private buffer;
|
|
13
|
+
isDone: boolean;
|
|
14
|
+
feed(chunk: string): SSEEvent[];
|
|
15
|
+
flush(): SSEEvent[];
|
|
16
|
+
private drainEvents;
|
|
17
|
+
private parseBlock;
|
|
18
|
+
/** 提取 field 冒号后的值,去除首个空格(SSE 规范: "data: value" -> "value") */
|
|
19
|
+
private extractFieldValue;
|
|
20
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class SSEParser {
|
|
2
|
+
buffer = "";
|
|
3
|
+
isDone = false;
|
|
4
|
+
feed(chunk) {
|
|
5
|
+
if (this.isDone)
|
|
6
|
+
return [];
|
|
7
|
+
this.buffer += chunk;
|
|
8
|
+
return this.drainEvents();
|
|
9
|
+
}
|
|
10
|
+
flush() {
|
|
11
|
+
const events = this.drainEvents();
|
|
12
|
+
// 末尾没有 \n\n 的残余数据也尝试解析
|
|
13
|
+
if (this.buffer.trim()) {
|
|
14
|
+
const event = this.parseBlock(this.buffer);
|
|
15
|
+
if (event)
|
|
16
|
+
events.push(event);
|
|
17
|
+
}
|
|
18
|
+
this.buffer = "";
|
|
19
|
+
return events;
|
|
20
|
+
}
|
|
21
|
+
drainEvents() {
|
|
22
|
+
const events = [];
|
|
23
|
+
// SSE 事件块以 \n\n 分隔
|
|
24
|
+
while (true) {
|
|
25
|
+
const idx = this.buffer.indexOf("\n\n");
|
|
26
|
+
if (idx === -1)
|
|
27
|
+
break;
|
|
28
|
+
const block = this.buffer.slice(0, idx);
|
|
29
|
+
// +2 跳过 "\n\n" 分隔符
|
|
30
|
+
this.buffer = this.buffer.slice(idx + "\n\n".length);
|
|
31
|
+
const event = this.parseBlock(block);
|
|
32
|
+
if (event)
|
|
33
|
+
events.push(event);
|
|
34
|
+
if (this.isDone)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
return events;
|
|
38
|
+
}
|
|
39
|
+
parseBlock(block) {
|
|
40
|
+
const lines = block.split("\n");
|
|
41
|
+
let eventType;
|
|
42
|
+
const dataLines = [];
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
// 空行在 block 内部忽略(block 边界已由 \n\n 处理)
|
|
45
|
+
if (line === "")
|
|
46
|
+
continue;
|
|
47
|
+
// SSE 注释行
|
|
48
|
+
if (line.startsWith(":"))
|
|
49
|
+
continue;
|
|
50
|
+
if (line.startsWith("event:")) {
|
|
51
|
+
eventType = this.extractFieldValue(line);
|
|
52
|
+
}
|
|
53
|
+
else if (line.startsWith("data:")) {
|
|
54
|
+
const value = this.extractFieldValue(line);
|
|
55
|
+
// [DONE] 是流结束信号,不作为普通事件返回
|
|
56
|
+
if (value === "[DONE]") {
|
|
57
|
+
this.isDone = true;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
dataLines.push(value);
|
|
61
|
+
}
|
|
62
|
+
// 其他 field(id:, retry:, etc.)按 SSE 规范忽略
|
|
63
|
+
}
|
|
64
|
+
// 没有 data 的事件块无意义
|
|
65
|
+
if (dataLines.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
event: eventType,
|
|
69
|
+
data: dataLines.join("\n"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** 提取 field 冒号后的值,去除首个空格(SSE 规范: "data: value" -> "value") */
|
|
73
|
+
extractFieldValue(line) {
|
|
74
|
+
const colonIdx = line.indexOf(":");
|
|
75
|
+
let value = line.slice(colonIdx + 1);
|
|
76
|
+
// 第一个字符是空格时去除
|
|
77
|
+
if (value.startsWith(" "))
|
|
78
|
+
value = value.slice(1);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FastifyPluginCallback } from "fastify";
|
|
2
|
+
interface AdminAuthOptions {
|
|
3
|
+
adminPassword: string;
|
|
4
|
+
jwtSecret: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const adminAuthPlugin: FastifyPluginCallback<AdminAuthOptions>;
|
|
7
|
+
export declare const adminLoginRoutes: FastifyPluginCallback<AdminAuthOptions>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
import cookie from "@fastify/cookie";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
import { timingSafeEqual } from "crypto";
|
|
5
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
6
|
+
const adminAuthRaw = (app, options, done) => {
|
|
7
|
+
app.register(cookie);
|
|
8
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
9
|
+
const path = request.url.split("?")[0];
|
|
10
|
+
if (!path.startsWith("/admin/api/") || path === "/admin/api/login" || path === "/admin/api/logout") {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const token = request.cookies["admin_token"];
|
|
14
|
+
if (!token) {
|
|
15
|
+
reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Not authenticated" } });
|
|
16
|
+
return reply;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
jwt.verify(token, options.jwtSecret);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
request.log.debug({ err }, "invalid JWT token");
|
|
23
|
+
reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid or expired token" } });
|
|
24
|
+
return reply;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
done();
|
|
28
|
+
};
|
|
29
|
+
export const adminAuthPlugin = fp(adminAuthRaw, { name: "admin-auth" });
|
|
30
|
+
export const adminLoginRoutes = (app, options, done) => {
|
|
31
|
+
const TOKEN_EXPIRY_SECONDS = 86400;
|
|
32
|
+
app.post("/admin/api/login", async (request, reply) => {
|
|
33
|
+
const { password } = request.body;
|
|
34
|
+
if (!password) {
|
|
35
|
+
return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
|
|
36
|
+
}
|
|
37
|
+
const passwordBuf = Buffer.from(password);
|
|
38
|
+
const keyBuf = Buffer.from(options.adminPassword);
|
|
39
|
+
if (passwordBuf.length !== keyBuf.length || !timingSafeEqual(passwordBuf, keyBuf)) {
|
|
40
|
+
return reply.code(HTTP_UNAUTHORIZED).send({ error: { message: "Invalid password" } });
|
|
41
|
+
}
|
|
42
|
+
const token = jwt.sign({ role: "admin" }, options.jwtSecret, { expiresIn: TOKEN_EXPIRY_SECONDS });
|
|
43
|
+
reply.setCookie("admin_token", token, {
|
|
44
|
+
path: "/admin",
|
|
45
|
+
httpOnly: true,
|
|
46
|
+
secure: process.env.NODE_ENV === "production",
|
|
47
|
+
sameSite: "lax",
|
|
48
|
+
maxAge: TOKEN_EXPIRY_SECONDS,
|
|
49
|
+
});
|
|
50
|
+
return reply.send({ success: true });
|
|
51
|
+
});
|
|
52
|
+
app.post("/admin/api/logout", async (_request, reply) => {
|
|
53
|
+
reply.clearCookie("admin_token", { path: "/admin" });
|
|
54
|
+
return reply.send({ success: true });
|
|
55
|
+
});
|
|
56
|
+
done();
|
|
57
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { FastifyPluginCallback } from "fastify";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
declare module "fastify" {
|
|
4
|
+
interface FastifyRequest {
|
|
5
|
+
routerKey?: {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
allowed_models: string | null;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export declare const authMiddleware: FastifyPluginCallback<{
|
|
13
|
+
db: Database.Database;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
const SKIP_PATHS = ["/health", "/admin"];
|
|
4
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
5
|
+
const BEARER_PREFIX_LENGTH = "Bearer ".length;
|
|
6
|
+
function shouldSkipAuth(url) {
|
|
7
|
+
const path = url.split("?")[0];
|
|
8
|
+
return SKIP_PATHS.some((prefix) => path === prefix || path.startsWith(prefix + "/"));
|
|
9
|
+
}
|
|
10
|
+
function unauthorizedReply(reply) {
|
|
11
|
+
reply.code(HTTP_UNAUTHORIZED).send({
|
|
12
|
+
error: {
|
|
13
|
+
message: "Invalid API key",
|
|
14
|
+
type: "invalid_request_error",
|
|
15
|
+
code: "invalid_api_key",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const authMiddlewareRaw = (app, options, done) => {
|
|
20
|
+
const stmt = options.db.prepare("SELECT id, name, allowed_models FROM router_keys WHERE key_hash = ? AND is_active = 1");
|
|
21
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
22
|
+
if (shouldSkipAuth(request.url)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const authHeader = request.headers.authorization;
|
|
26
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
27
|
+
unauthorizedReply(reply);
|
|
28
|
+
return reply;
|
|
29
|
+
}
|
|
30
|
+
const token = authHeader.slice(BEARER_PREFIX_LENGTH);
|
|
31
|
+
const hash = createHash("sha256").update(token).digest("hex");
|
|
32
|
+
const row = stmt.get(hash);
|
|
33
|
+
if (!row) {
|
|
34
|
+
unauthorizedReply(reply);
|
|
35
|
+
return reply;
|
|
36
|
+
}
|
|
37
|
+
request.routerKey = { id: row.id, name: row.name, allowed_models: row.allowed_models };
|
|
38
|
+
});
|
|
39
|
+
done();
|
|
40
|
+
};
|
|
41
|
+
export const authMiddleware = fp(authMiddlewareRaw, { name: "auth-middleware" });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { FastifyPluginCallback } from "fastify";
|
|
3
|
+
import { RetryRuleMatcher } from "./retry-rules.js";
|
|
4
|
+
export interface AnthropicProxyOptions {
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
encryptionKey: string;
|
|
7
|
+
streamTimeoutMs: number;
|
|
8
|
+
retryMaxAttempts: number;
|
|
9
|
+
retryBaseDelayMs: number;
|
|
10
|
+
matcher?: RetryRuleMatcher;
|
|
11
|
+
}
|
|
12
|
+
export declare const anthropicProxy: FastifyPluginCallback<AnthropicProxyOptions>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
import { handleProxyPost, } from "./proxy-core.js";
|
|
3
|
+
const MESSAGES_PATH = "/v1/messages";
|
|
4
|
+
const anthropicErrors = {
|
|
5
|
+
modelNotFound: (model) => ({
|
|
6
|
+
statusCode: 404,
|
|
7
|
+
body: { type: "error", error: { type: "not_found_error", message: `Model '${model}' is not configured` } },
|
|
8
|
+
}),
|
|
9
|
+
modelNotAllowed: (model) => ({
|
|
10
|
+
statusCode: 403,
|
|
11
|
+
body: { type: "error", error: { type: "forbidden_error", message: `Model '${model}' is not allowed for this API key` } },
|
|
12
|
+
}),
|
|
13
|
+
providerUnavailable: () => ({
|
|
14
|
+
statusCode: 503,
|
|
15
|
+
body: { type: "error", error: { type: "api_error", message: "Provider unavailable" } },
|
|
16
|
+
}),
|
|
17
|
+
providerTypeMismatch: () => ({
|
|
18
|
+
statusCode: 500,
|
|
19
|
+
body: { type: "error", error: { type: "api_error", message: "Provider type mismatch for this endpoint" } },
|
|
20
|
+
}),
|
|
21
|
+
upstreamConnectionFailed: () => ({
|
|
22
|
+
statusCode: 502,
|
|
23
|
+
body: { type: "error", error: { type: "upstream_error", message: "Failed to connect to upstream service" } },
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
const anthropicProxyRaw = (app, opts, done) => {
|
|
27
|
+
const { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
|
|
28
|
+
app.post(MESSAGES_PATH, async (request, reply) => {
|
|
29
|
+
const deps = { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher };
|
|
30
|
+
return handleProxyPost(request, reply, "anthropic", MESSAGES_PATH, anthropicErrors, deps);
|
|
31
|
+
});
|
|
32
|
+
done();
|
|
33
|
+
};
|
|
34
|
+
export const anthropicProxy = fp(anthropicProxyRaw, { name: "anthropic-proxy" });
|