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.
Files changed (124) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +121 -0
  4. package/dist/admin/constants.d.ts +10 -0
  5. package/dist/admin/constants.js +11 -0
  6. package/dist/admin/groups.d.ts +7 -0
  7. package/dist/admin/groups.js +118 -0
  8. package/dist/admin/logs.d.ts +7 -0
  9. package/dist/admin/logs.js +43 -0
  10. package/dist/admin/mappings.d.ts +7 -0
  11. package/dist/admin/mappings.js +120 -0
  12. package/dist/admin/metrics.d.ts +7 -0
  13. package/dist/admin/metrics.js +41 -0
  14. package/dist/admin/providers.d.ts +8 -0
  15. package/dist/admin/providers.js +101 -0
  16. package/dist/admin/retry-rules.d.ts +9 -0
  17. package/dist/admin/retry-rules.js +98 -0
  18. package/dist/admin/router-keys.d.ts +8 -0
  19. package/dist/admin/router-keys.js +85 -0
  20. package/dist/admin/routes.d.ts +12 -0
  21. package/dist/admin/routes.js +22 -0
  22. package/dist/admin/services.d.ts +7 -0
  23. package/dist/admin/services.js +63 -0
  24. package/dist/admin/stats.d.ts +7 -0
  25. package/dist/admin/stats.js +15 -0
  26. package/dist/config.d.ts +15 -0
  27. package/dist/config.js +28 -0
  28. package/dist/db/helpers.d.ts +12 -0
  29. package/dist/db/helpers.js +28 -0
  30. package/dist/db/index.d.ts +16 -0
  31. package/dist/db/index.js +45 -0
  32. package/dist/db/logs.d.ts +90 -0
  33. package/dist/db/logs.js +47 -0
  34. package/dist/db/mappings.d.ts +36 -0
  35. package/dist/db/mappings.js +55 -0
  36. package/dist/db/metrics.d.ts +24 -0
  37. package/dist/db/metrics.js +119 -0
  38. package/dist/db/migrations/001_init.sql +37 -0
  39. package/dist/db/migrations/002_add_request_response_body.sql +2 -0
  40. package/dist/db/migrations/003_add_full_request_chain_log.sql +4 -0
  41. package/dist/db/migrations/004_rename_to_providers.sql +9 -0
  42. package/dist/db/migrations/005_add_api_key_preview.sql +1 -0
  43. package/dist/db/migrations/006_create_request_metrics.sql +20 -0
  44. package/dist/db/migrations/007_add_retry_fields.sql +2 -0
  45. package/dist/db/migrations/008_create_router_keys.sql +17 -0
  46. package/dist/db/migrations/009_add_request_logs_indexes.sql +2 -0
  47. package/dist/db/migrations/010_add_key_encrypted.sql +1 -0
  48. package/dist/db/migrations/011_create_mapping_groups.sql +33 -0
  49. package/dist/db/migrations/012_add_provider_models.sql +2 -0
  50. package/dist/db/migrations/013_add_retry_strategy.sql +4 -0
  51. package/dist/db/providers.d.ts +27 -0
  52. package/dist/db/providers.js +29 -0
  53. package/dist/db/retry-rules.d.ts +32 -0
  54. package/dist/db/retry-rules.js +49 -0
  55. package/dist/db/router-keys.d.ts +29 -0
  56. package/dist/db/router-keys.js +36 -0
  57. package/dist/db/stats.d.ts +9 -0
  58. package/dist/db/stats.js +34 -0
  59. package/dist/index.d.ts +13 -0
  60. package/dist/index.js +131 -0
  61. package/dist/metrics/metrics-extractor.d.ts +32 -0
  62. package/dist/metrics/metrics-extractor.js +178 -0
  63. package/dist/metrics/sse-metrics-transform.d.ts +16 -0
  64. package/dist/metrics/sse-metrics-transform.js +35 -0
  65. package/dist/metrics/sse-parser.d.ts +20 -0
  66. package/dist/metrics/sse-parser.js +81 -0
  67. package/dist/middleware/admin-auth.d.ts +8 -0
  68. package/dist/middleware/admin-auth.js +57 -0
  69. package/dist/middleware/auth.d.ts +14 -0
  70. package/dist/middleware/auth.js +41 -0
  71. package/dist/proxy/anthropic.d.ts +12 -0
  72. package/dist/proxy/anthropic.js +34 -0
  73. package/dist/proxy/mapping-resolver.d.ts +3 -0
  74. package/dist/proxy/mapping-resolver.js +27 -0
  75. package/dist/proxy/openai.d.ts +12 -0
  76. package/dist/proxy/openai.js +72 -0
  77. package/dist/proxy/proxy-core.d.ts +75 -0
  78. package/dist/proxy/proxy-core.js +408 -0
  79. package/dist/proxy/retry-rules.d.ts +9 -0
  80. package/dist/proxy/retry-rules.js +27 -0
  81. package/dist/proxy/retry.d.ts +43 -0
  82. package/dist/proxy/retry.js +120 -0
  83. package/dist/proxy/strategy/failover.d.ts +4 -0
  84. package/dist/proxy/strategy/failover.js +5 -0
  85. package/dist/proxy/strategy/random.d.ts +4 -0
  86. package/dist/proxy/strategy/random.js +5 -0
  87. package/dist/proxy/strategy/round-robin.d.ts +4 -0
  88. package/dist/proxy/strategy/round-robin.js +5 -0
  89. package/dist/proxy/strategy/scheduled.d.ts +4 -0
  90. package/dist/proxy/strategy/scheduled.js +62 -0
  91. package/dist/proxy/strategy/types.d.ts +13 -0
  92. package/dist/proxy/strategy/types.js +3 -0
  93. package/dist/utils/crypto.d.ts +2 -0
  94. package/dist/utils/crypto.js +32 -0
  95. package/frontend-dist/assets/CardContent-BE9fukPi.js +1 -0
  96. package/frontend-dist/assets/CardHeader-D5lVaeAA.js +1 -0
  97. package/frontend-dist/assets/CardTitle-H-zwhi3Z.js +1 -0
  98. package/frontend-dist/assets/Checkbox--1gw0dYW.js +1 -0
  99. package/frontend-dist/assets/CollapsibleTrigger-D_ptA35Y.js +1 -0
  100. package/frontend-dist/assets/Dashboard-D4AwkULO.js +3 -0
  101. package/frontend-dist/assets/Label-GiPfoz7u.js +1 -0
  102. package/frontend-dist/assets/Login-BUet1sbM.js +1 -0
  103. package/frontend-dist/assets/Logs-yztb_F9t.js +3 -0
  104. package/frontend-dist/assets/ModelMappings-MbZhdPNv.js +1 -0
  105. package/frontend-dist/assets/Providers-BjsqH6A2.js +1 -0
  106. package/frontend-dist/assets/RetryRules-C2vvJvLr.js +1 -0
  107. package/frontend-dist/assets/RouterKeys-DavrgpAQ.js +1 -0
  108. package/frontend-dist/assets/RovingFocusItem-DnIa_lwH.js +1 -0
  109. package/frontend-dist/assets/SelectValue-BB0Ckbjh.js +1 -0
  110. package/frontend-dist/assets/TableHeader-D2GkiqRx.js +1 -0
  111. package/frontend-dist/assets/alert-dialog-CWjBke-O.js +1 -0
  112. package/frontend-dist/assets/badge-_ZHrMEpC.js +3 -0
  113. package/frontend-dist/assets/button-C4_mChkc.js +1 -0
  114. package/frontend-dist/assets/client-BWw0R36V.js +12 -0
  115. package/frontend-dist/assets/dialog-CUHMcTqp.js +1 -0
  116. package/frontend-dist/assets/index-DEl48bm9.css +1 -0
  117. package/frontend-dist/assets/index-UZK1BnPG.js +1 -0
  118. package/frontend-dist/assets/lib-Qs8xoTas.js +1 -0
  119. package/frontend-dist/assets/useForwardExpose-B-xauF1X.js +1 -0
  120. package/frontend-dist/assets/x-JBJB26JV.js +1 -0
  121. package/frontend-dist/favicon.svg +1 -0
  122. package/frontend-dist/icons.svg +24 -0
  123. package/frontend-dist/index.html +18 -0
  124. package/package.json +72 -0
@@ -0,0 +1,27 @@
1
+ import { STRATEGY_NAMES } from "./strategy/types.js";
2
+ import { ScheduledStrategy } from "./strategy/scheduled.js";
3
+ import { getMappingGroup } from "../db/index.js";
4
+ // 策略注册表:key 为数据库中 mapping_groups.strategy 字段的值。
5
+ // 新增策略时:
6
+ // 1. 在 src/proxy/strategy/ 下创建实现文件
7
+ // 2. 在此注册表中添加映射
8
+ const STRATEGIES = {
9
+ [STRATEGY_NAMES.SCHEDULED]: new ScheduledStrategy(),
10
+ };
11
+ export function resolveMapping(db, clientModel, context) {
12
+ const group = getMappingGroup(db, clientModel);
13
+ if (!group)
14
+ return null;
15
+ let rule;
16
+ try {
17
+ rule = JSON.parse(group.rule);
18
+ }
19
+ catch {
20
+ console.warn(`[mapping-resolver] Failed to parse rule for client_model '${group.client_model}'`);
21
+ return null;
22
+ }
23
+ const strategy = STRATEGIES[group.strategy];
24
+ if (!strategy)
25
+ return null;
26
+ return strategy.select(rule, context) ?? null;
27
+ }
@@ -0,0 +1,12 @@
1
+ import type { FastifyPluginCallback } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import { RetryRuleMatcher } from "./retry-rules.js";
4
+ export interface OpenaiProxyOptions {
5
+ db: Database.Database;
6
+ encryptionKey: string;
7
+ streamTimeoutMs: number;
8
+ retryMaxAttempts: number;
9
+ retryBaseDelayMs: number;
10
+ matcher?: RetryRuleMatcher;
11
+ }
12
+ export declare const openaiProxy: FastifyPluginCallback<OpenaiProxyOptions>;
@@ -0,0 +1,72 @@
1
+ import fp from "fastify-plugin";
2
+ import { getActiveProviders } from "../db/index.js";
3
+ import { decrypt } from "../utils/crypto.js";
4
+ import { proxyGetRequest, handleProxyPost, } from "./proxy-core.js";
5
+ const HTTP_NOT_FOUND = 404;
6
+ const HTTP_BAD_GATEWAY = 502;
7
+ const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
8
+ const MODELS_PATH = "/v1/models";
9
+ const openaiErrors = {
10
+ modelNotFound: (model) => ({
11
+ statusCode: 404,
12
+ body: { error: { message: `Model '${model}' is not configured`, type: "invalid_request_error", code: "model_not_found" } },
13
+ }),
14
+ modelNotAllowed: (model) => ({
15
+ statusCode: 403,
16
+ body: { error: { message: `Model '${model}' is not allowed for this API key`, type: "invalid_request_error", code: "model_not_allowed" } },
17
+ }),
18
+ providerUnavailable: () => ({
19
+ statusCode: 503,
20
+ body: { error: { message: "Provider unavailable", type: "server_error", code: "provider_unavailable" } },
21
+ }),
22
+ providerTypeMismatch: () => ({
23
+ statusCode: 500,
24
+ body: { error: { message: "Provider type mismatch for this endpoint", type: "server_error", code: "provider_type_mismatch" } },
25
+ }),
26
+ upstreamConnectionFailed: () => ({
27
+ statusCode: 502,
28
+ body: { error: { message: "Failed to connect to upstream service", type: "upstream_error", code: "upstream_connection_failed" } },
29
+ }),
30
+ };
31
+ function sendError(reply, e) {
32
+ return reply.status(e.statusCode).send(e.body);
33
+ }
34
+ const openaiProxyRaw = (app, opts, done) => {
35
+ const { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
36
+ app.post(CHAT_COMPLETIONS_PATH, async (request, reply) => {
37
+ const deps = { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher };
38
+ return handleProxyPost(request, reply, "openai", CHAT_COMPLETIONS_PATH, openaiErrors, deps, {
39
+ beforeSendProxy: (body, isStream) => {
40
+ if (isStream && !body.stream_options) {
41
+ body.stream_options = { include_usage: true };
42
+ }
43
+ },
44
+ });
45
+ });
46
+ app.get(MODELS_PATH, async (request, reply) => {
47
+ const providers = getActiveProviders(db, "openai");
48
+ if (providers.length === 0)
49
+ return sendError(reply, {
50
+ statusCode: HTTP_NOT_FOUND,
51
+ body: { error: { message: "No active OpenAI provider configured", type: "invalid_request_error", code: "no_provider" } },
52
+ });
53
+ const provider = providers[0];
54
+ const apiKey = decrypt(provider.api_key, encryptionKey);
55
+ const cliHdrs = request.headers;
56
+ try {
57
+ const result = await proxyGetRequest(provider, apiKey, cliHdrs, MODELS_PATH);
58
+ for (const [k, v] of Object.entries(result.headers))
59
+ reply.header(k, v);
60
+ return reply.status(result.statusCode).send(result.body);
61
+ }
62
+ catch (err) {
63
+ request.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to reach OpenAI backend for /v1/models");
64
+ return sendError(reply, {
65
+ statusCode: HTTP_BAD_GATEWAY,
66
+ body: { error: { message: "Failed to reach backend service", type: "server_error", code: "upstream_error" } },
67
+ });
68
+ }
69
+ });
70
+ done();
71
+ };
72
+ export const openaiProxy = fp(openaiProxyRaw, { name: "openai-proxy" });
@@ -0,0 +1,75 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import Database from "better-sqlite3";
3
+ import type { Provider } from "../db/index.js";
4
+ import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
5
+ import type { MetricsResult } from "../metrics/metrics-extractor.js";
6
+ import type { RetryRuleMatcher } from "./retry-rules.js";
7
+ export type RawHeaders = Record<string, string | string[] | undefined>;
8
+ export interface UpstreamRequestOptions {
9
+ hostname: string;
10
+ port: number;
11
+ path: string;
12
+ method: string;
13
+ headers: Record<string, string>;
14
+ }
15
+ export interface ProxyResult {
16
+ statusCode: number;
17
+ body: string;
18
+ headers: Record<string, string>;
19
+ sentHeaders: Record<string, string>;
20
+ sentBody: string;
21
+ }
22
+ export interface StreamProxyResult {
23
+ statusCode: number;
24
+ responseBody?: string;
25
+ upstreamResponseHeaders?: Record<string, string>;
26
+ sentHeaders?: Record<string, string>;
27
+ /** 流结束时从 SSEMetricsTransform 采集的指标,仅当传入了 metricsTransform 时存在 */
28
+ metricsResult?: MetricsResult;
29
+ }
30
+ export interface GetProxyResult {
31
+ statusCode: number;
32
+ body: string;
33
+ headers: Record<string, string>;
34
+ }
35
+ export declare const UPSTREAM_SUCCESS = 200;
36
+ export declare const SKIP_UPSTREAM: Set<string>;
37
+ export declare const SKIP_DOWNSTREAM: Set<string>;
38
+ export declare function selectHeaders(raw: RawHeaders, skip: Set<string>): Record<string, string>;
39
+ /** 构建发往上游的请求 headers:过滤客户端 headers + 注入后端 API key */
40
+ export declare function buildUpstreamHeaders(clientHeaders: RawHeaders, apiKey: string, payloadBytes?: number): Record<string, string>;
41
+ /** 根据 URL scheme 选择 http 或 https 模块 */
42
+ export declare function createUpstreamRequest(url: URL, options: UpstreamRequestOptions): import("http").ClientRequest;
43
+ /** 从 URL + headers 构造 Node.js http.request 所需的 options */
44
+ export declare function buildRequestOptions(url: URL, headers: Record<string, string>, method?: string): UpstreamRequestOptions;
45
+ /** 插入成功请求日志,供 openai/anthropic 插件共享 */
46
+ export declare function insertSuccessLog(db: Database.Database, apiType: string, logId: string, model: string, provider: Provider, isStream: boolean, startTime: number, reqBody: string, clientReq: string, upstreamReq: string, status: number, respBody: string | null, upHdrs: Record<string, string>, cliHdrs: Record<string, string>, isRetry?: boolean, originalRequestId?: string | null, routerKeyId?: string | null): void;
47
+ export declare function proxyNonStream(backend: Provider, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, upstreamPath: string): Promise<ProxyResult>;
48
+ export declare function proxyStream(backend: Provider, apiKey: string, body: Record<string, unknown>, clientHeaders: RawHeaders, reply: FastifyReply, timeoutMs: number, upstreamPath: string, metricsTransform?: SSEMetricsTransform): Promise<StreamProxyResult>;
49
+ export declare function proxyGetRequest(backend: Provider, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string): Promise<GetProxyResult>;
50
+ export interface ProxyErrorResponse {
51
+ statusCode: number;
52
+ body: unknown;
53
+ }
54
+ export interface ProxyErrorFormatter {
55
+ modelNotFound(model: string): ProxyErrorResponse;
56
+ modelNotAllowed(model: string): ProxyErrorResponse;
57
+ providerUnavailable(): ProxyErrorResponse;
58
+ providerTypeMismatch(): ProxyErrorResponse;
59
+ upstreamConnectionFailed(): ProxyErrorResponse;
60
+ }
61
+ export interface ProxyHandlerDeps {
62
+ db: Database.Database;
63
+ encryptionKey: string;
64
+ streamTimeoutMs: number;
65
+ retryMaxAttempts: number;
66
+ retryBaseDelayMs: number;
67
+ matcher?: RetryRuleMatcher;
68
+ }
69
+ /**
70
+ * 共享 POST handler,参数化 apiType/errorFormat/upstreamPath 等差异,
71
+ * 消除 openai.ts / anthropic.ts 中约 120 行重复代码。
72
+ */
73
+ export declare function handleProxyPost(request: FastifyRequest, reply: FastifyReply, apiType: "openai" | "anthropic", upstreamPath: string, errors: ProxyErrorFormatter, deps: ProxyHandlerDeps, options?: {
74
+ beforeSendProxy?: (body: Record<string, unknown>, isStream: boolean) => void;
75
+ }): Promise<FastifyReply>;
@@ -0,0 +1,408 @@
1
+ import { randomUUID } from "crypto";
2
+ import { request as httpRequestFn } from "http";
3
+ import { request as httpsRequestFn } from "https";
4
+ import { PassThrough } from "stream";
5
+ import { getProviderById, insertRequestLog, insertMetrics } from "../db/index.js";
6
+ import { decrypt } from "../utils/crypto.js";
7
+ import { SSEMetricsTransform } from "../metrics/sse-metrics-transform.js";
8
+ import { MetricsExtractor } from "../metrics/metrics-extractor.js";
9
+ import { resolveMapping } from "./mapping-resolver.js";
10
+ import { retryableCall, buildRetryConfig } from "./retry.js";
11
+ // ---------- Constants ----------
12
+ export const UPSTREAM_SUCCESS = 200;
13
+ const HTTPS_DEFAULT_PORT = 443;
14
+ const HTTP_DEFAULT_PORT = 80;
15
+ const UPSTREAM_BAD_GATEWAY = 502;
16
+ // ---------- Header utilities ----------
17
+ export const SKIP_UPSTREAM = new Set([
18
+ "host",
19
+ "content-length",
20
+ "accept-encoding",
21
+ "authorization",
22
+ "connection",
23
+ "keep-alive",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ ]);
27
+ export const SKIP_DOWNSTREAM = new Set([
28
+ "content-length",
29
+ "transfer-encoding",
30
+ "connection",
31
+ "keep-alive",
32
+ ]);
33
+ export function selectHeaders(raw, skip) {
34
+ const out = {};
35
+ for (const [key, value] of Object.entries(raw)) {
36
+ if (value == null || skip.has(key.toLowerCase()))
37
+ continue;
38
+ out[key] = Array.isArray(value) ? value.join(", ") : value;
39
+ }
40
+ return out;
41
+ }
42
+ // 当前两个 provider 都使用 Bearer token(commit eaa4f7d 将 Anthropic 从 x-api-key 改为 Bearer)
43
+ // 如果未来需要支持其他鉴权方式,需要参数化 header 构造
44
+ /** 构建发往上游的请求 headers:过滤客户端 headers + 注入后端 API key */
45
+ export function buildUpstreamHeaders(clientHeaders, apiKey, payloadBytes) {
46
+ const headers = selectHeaders(clientHeaders, SKIP_UPSTREAM);
47
+ headers["Authorization"] = `Bearer ${apiKey}`;
48
+ if (payloadBytes !== undefined) {
49
+ headers["Content-Type"] = "application/json";
50
+ headers["Content-Length"] = String(payloadBytes);
51
+ }
52
+ return headers;
53
+ }
54
+ // ---------- Request utilities ----------
55
+ /** 根据 URL scheme 选择 http 或 https 模块 */
56
+ export function createUpstreamRequest(url, options) {
57
+ return url.protocol === "https:" ? httpsRequestFn(options) : httpRequestFn(options);
58
+ }
59
+ /** 从 URL + headers 构造 Node.js http.request 所需的 options */
60
+ export function buildRequestOptions(url, headers, method = "POST") {
61
+ return {
62
+ hostname: url.hostname,
63
+ port: Number(url.port) || (url.protocol === "https:" ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT),
64
+ path: url.pathname,
65
+ method,
66
+ headers,
67
+ };
68
+ }
69
+ // ---------- Logging ----------
70
+ /** 插入成功请求日志,供 openai/anthropic 插件共享 */
71
+ export function insertSuccessLog(db, apiType, logId, model, provider, isStream, startTime, reqBody, clientReq, upstreamReq, status, respBody, upHdrs, cliHdrs, isRetry = false, originalRequestId = null, routerKeyId = null) {
72
+ insertRequestLog(db, {
73
+ id: logId, api_type: apiType, model, provider_id: provider.id,
74
+ status_code: status, latency_ms: Date.now() - startTime,
75
+ is_stream: isStream ? 1 : 0, error_message: null,
76
+ created_at: new Date().toISOString(), request_body: reqBody,
77
+ response_body: respBody, client_request: clientReq, upstream_request: upstreamReq,
78
+ upstream_response: JSON.stringify({ statusCode: status, headers: upHdrs, body: respBody }),
79
+ client_response: JSON.stringify({ statusCode: status, headers: cliHdrs, body: respBody }),
80
+ is_retry: isRetry ? 1 : 0, original_request_id: originalRequestId,
81
+ router_key_id: routerKeyId,
82
+ });
83
+ }
84
+ // ---------- Non-stream proxy ----------
85
+ export function proxyNonStream(backend, apiKey, body, clientHeaders, upstreamPath) {
86
+ return new Promise((resolve, reject) => {
87
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
88
+ const payload = JSON.stringify(body);
89
+ const upstreamHeaders = buildUpstreamHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
90
+ const options = buildRequestOptions(url, upstreamHeaders);
91
+ const req = createUpstreamRequest(url, options);
92
+ req.on("response", (res) => {
93
+ const chunks = [];
94
+ res.on("data", (chunk) => chunks.push(chunk));
95
+ res.on("end", () => {
96
+ resolve({
97
+ statusCode: res.statusCode || UPSTREAM_BAD_GATEWAY,
98
+ body: Buffer.concat(chunks).toString("utf-8"),
99
+ headers: selectHeaders(res.headers, SKIP_DOWNSTREAM),
100
+ sentHeaders: { ...upstreamHeaders },
101
+ sentBody: payload,
102
+ });
103
+ });
104
+ });
105
+ req.on("error", (err) => reject(err));
106
+ req.write(payload);
107
+ req.end();
108
+ });
109
+ }
110
+ // ---------- Stream proxy (SSE) ----------
111
+ export function proxyStream(backend, apiKey, body, clientHeaders, reply, timeoutMs, upstreamPath, metricsTransform) {
112
+ return new Promise((resolve, reject) => {
113
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
114
+ const payload = JSON.stringify(body);
115
+ const upstreamHeaders = buildUpstreamHeaders(clientHeaders, apiKey, Buffer.byteLength(payload));
116
+ const options = buildRequestOptions(url, upstreamHeaders);
117
+ const upstreamReq = createUpstreamRequest(url, options);
118
+ upstreamReq.on("response", (upstreamRes) => {
119
+ const statusCode = upstreamRes.statusCode || UPSTREAM_BAD_GATEWAY;
120
+ if (statusCode !== UPSTREAM_SUCCESS) {
121
+ // 非200路径:仅返回错误信息,不操作 reply
122
+ const chunks = [];
123
+ upstreamRes.on("data", (chunk) => chunks.push(chunk));
124
+ upstreamRes.on("end", () => {
125
+ const errorBody = Buffer.concat(chunks).toString("utf-8");
126
+ resolve({
127
+ statusCode,
128
+ responseBody: errorBody,
129
+ upstreamResponseHeaders: selectHeaders(upstreamRes.headers, SKIP_DOWNSTREAM),
130
+ sentHeaders: upstreamHeaders,
131
+ });
132
+ });
133
+ return;
134
+ }
135
+ const sseHeaders = selectHeaders(upstreamRes.headers, SKIP_DOWNSTREAM);
136
+ sseHeaders["Content-Type"] = "text/event-stream";
137
+ sseHeaders["Cache-Control"] = "no-cache";
138
+ sseHeaders["Connection"] = "keep-alive";
139
+ reply.raw.writeHead(statusCode, sseHeaders);
140
+ const passThrough = new PassThrough();
141
+ if (metricsTransform) {
142
+ // 管道: upstreamRes → metricsTransform → passThrough → reply.raw
143
+ metricsTransform.pipe(passThrough).pipe(reply.raw);
144
+ }
145
+ else {
146
+ passThrough.pipe(reply.raw);
147
+ }
148
+ // 管道入口:有 metricsTransform 时写入它,否则直接写 passThrough
149
+ const pipeEntry = metricsTransform ?? passThrough;
150
+ const captureChunks = [];
151
+ let idleTimer = null;
152
+ let resolved = false;
153
+ function cleanup() {
154
+ if (idleTimer)
155
+ clearTimeout(idleTimer);
156
+ idleTimer = null;
157
+ if (!passThrough.destroyed)
158
+ passThrough.destroy();
159
+ if (metricsTransform && !metricsTransform.destroyed)
160
+ metricsTransform.destroy();
161
+ if (!upstreamRes.destroyed)
162
+ upstreamRes.destroy();
163
+ }
164
+ /** 从 metricsTransform 中提取指标,供 resolve 时附带 */
165
+ function collectMetrics(isComplete) {
166
+ if (!metricsTransform)
167
+ return undefined;
168
+ const result = metricsTransform.getExtractor().getMetrics();
169
+ if (!isComplete) {
170
+ return { ...result, is_complete: 0 };
171
+ }
172
+ return result;
173
+ }
174
+ reply.raw.on("close", () => {
175
+ if (!resolved) {
176
+ cleanup();
177
+ resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
178
+ }
179
+ });
180
+ passThrough.on("error", () => {
181
+ cleanup();
182
+ if (!resolved) {
183
+ resolved = true;
184
+ resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
185
+ }
186
+ });
187
+ function resetIdleTimer() {
188
+ if (idleTimer)
189
+ clearTimeout(idleTimer);
190
+ idleTimer = setTimeout(() => {
191
+ cleanup();
192
+ if (!resolved) {
193
+ resolved = true;
194
+ resolve({ statusCode, responseBody: undefined, upstreamResponseHeaders: sseHeaders, sentHeaders: upstreamHeaders, metricsResult: collectMetrics(false) });
195
+ }
196
+ }, timeoutMs);
197
+ }
198
+ resetIdleTimer();
199
+ upstreamRes.on("data", (chunk) => {
200
+ if (resolved)
201
+ return;
202
+ resetIdleTimer();
203
+ pipeEntry.write(chunk);
204
+ captureChunks.push(chunk);
205
+ });
206
+ upstreamRes.on("end", () => {
207
+ if (resolved)
208
+ return;
209
+ resolved = true;
210
+ if (idleTimer)
211
+ clearTimeout(idleTimer);
212
+ pipeEntry.end();
213
+ reply.raw.end();
214
+ resolve({
215
+ statusCode,
216
+ responseBody: Buffer.concat(captureChunks).toString("utf-8"),
217
+ upstreamResponseHeaders: sseHeaders,
218
+ sentHeaders: upstreamHeaders,
219
+ metricsResult: collectMetrics(true),
220
+ });
221
+ });
222
+ upstreamRes.on("error", (err) => {
223
+ if (resolved)
224
+ return;
225
+ resolved = true;
226
+ cleanup();
227
+ reject(err);
228
+ });
229
+ });
230
+ upstreamReq.on("error", (err) => reject(err));
231
+ upstreamReq.write(payload);
232
+ upstreamReq.end();
233
+ });
234
+ }
235
+ // ---------- GET proxy ----------
236
+ export function proxyGetRequest(backend, apiKey, clientHeaders, upstreamPath) {
237
+ return new Promise((resolve, reject) => {
238
+ const url = new URL(`${backend.base_url}${upstreamPath}`);
239
+ const headers = buildUpstreamHeaders(clientHeaders, apiKey);
240
+ const options = buildRequestOptions(url, headers, "GET");
241
+ const req = createUpstreamRequest(url, options);
242
+ req.on("response", (res) => {
243
+ const chunks = [];
244
+ res.on("data", (chunk) => chunks.push(chunk));
245
+ res.on("end", () => {
246
+ resolve({
247
+ statusCode: res.statusCode || UPSTREAM_BAD_GATEWAY,
248
+ body: Buffer.concat(chunks).toString("utf-8"),
249
+ headers: selectHeaders(res.headers, SKIP_DOWNSTREAM),
250
+ });
251
+ });
252
+ });
253
+ req.on("error", (err) => reject(err));
254
+ req.end();
255
+ });
256
+ }
257
+ const HTTP_BAD_GATEWAY = 502;
258
+ /**
259
+ * 共享 POST handler,参数化 apiType/errorFormat/upstreamPath 等差异,
260
+ * 消除 openai.ts / anthropic.ts 中约 120 行重复代码。
261
+ */
262
+ export async function handleProxyPost(request, reply, apiType, upstreamPath, errors, deps, options) {
263
+ const { db, encryptionKey, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = deps;
264
+ request.raw.socket.on("error", (err) => request.log.debug({ err }, "client socket error"));
265
+ const startTime = Date.now();
266
+ const logId = randomUUID();
267
+ const routerKeyId = request.routerKey?.id ?? null;
268
+ const body = request.body;
269
+ const originalBody = JSON.parse(JSON.stringify(body));
270
+ const clientModel = body.model || "unknown";
271
+ const resolved = resolveMapping(db, clientModel, { now: new Date() });
272
+ if (!resolved) {
273
+ const e = errors.modelNotFound(clientModel);
274
+ return reply.status(e.statusCode).send(e.body);
275
+ }
276
+ // 白名单校验
277
+ const allowedModels = request.routerKey?.allowed_models;
278
+ if (allowedModels) {
279
+ try {
280
+ const models = JSON.parse(allowedModels);
281
+ if (models.length > 0 && !models.includes(resolved.backend_model)) {
282
+ const e = errors.modelNotAllowed(resolved.backend_model);
283
+ return reply.status(e.statusCode).send(e.body);
284
+ }
285
+ }
286
+ catch {
287
+ request.log.warn("Invalid allowed_models JSON, allowing all models");
288
+ }
289
+ }
290
+ const provider = getProviderById(db, resolved.provider_id);
291
+ if (!provider || !provider.is_active) {
292
+ const e = errors.providerUnavailable();
293
+ return reply.status(e.statusCode).send(e.body);
294
+ }
295
+ if (provider.api_type !== apiType) {
296
+ const e = errors.providerTypeMismatch();
297
+ return reply.status(e.statusCode).send(e.body);
298
+ }
299
+ body.model = resolved.backend_model;
300
+ const apiKey = decrypt(provider.api_key, encryptionKey);
301
+ const isStream = body.stream === true;
302
+ // 允许调用方在发送代理请求前修改 body(如 openai 的 stream_options 注入)
303
+ options?.beforeSendProxy?.(body, isStream);
304
+ const reqBodyStr = JSON.stringify(body);
305
+ const cliHdrs = request.headers;
306
+ const clientReq = JSON.stringify({ headers: cliHdrs, body: originalBody });
307
+ const retryConfig = buildRetryConfig(retryMaxAttempts, retryBaseDelayMs, matcher);
308
+ const upstreamReqBase = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr)), body: reqBodyStr });
309
+ try {
310
+ const { result: r, attempts } = isStream
311
+ ? await retryableCall(() => {
312
+ const metricsTransform = new SSEMetricsTransform(apiType, startTime);
313
+ return proxyStream(provider, apiKey, body, cliHdrs, reply, streamTimeoutMs, upstreamPath, metricsTransform);
314
+ }, retryConfig, reply)
315
+ : await retryableCall(() => proxyNonStream(provider, apiKey, body, cliHdrs, upstreamPath), retryConfig, reply);
316
+ // 记录所有尝试的日志
317
+ let lastSuccessLogId = logId;
318
+ for (const attempt of attempts) {
319
+ const isOriginal = attempt.attemptIndex === 0;
320
+ const attemptLogId = isOriginal ? logId : randomUUID();
321
+ if (attempt.error) {
322
+ insertRequestLog(db, {
323
+ id: attemptLogId, api_type: apiType, model: clientModel, provider_id: provider.id,
324
+ status_code: HTTP_BAD_GATEWAY, latency_ms: attempt.latencyMs,
325
+ is_stream: isStream ? 1 : 0, error_message: attempt.error,
326
+ created_at: new Date().toISOString(), request_body: reqBodyStr,
327
+ client_request: clientReq, upstream_request: upstreamReqBase,
328
+ is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
329
+ router_key_id: routerKeyId,
330
+ });
331
+ }
332
+ else if (attempt.statusCode !== UPSTREAM_SUCCESS) {
333
+ insertRequestLog(db, {
334
+ id: attemptLogId, api_type: apiType, model: clientModel, provider_id: provider.id,
335
+ status_code: attempt.statusCode, latency_ms: attempt.latencyMs,
336
+ is_stream: isStream ? 1 : 0, error_message: null,
337
+ created_at: new Date().toISOString(), request_body: reqBodyStr,
338
+ response_body: attempt.responseBody, client_request: clientReq, upstream_request: upstreamReqBase,
339
+ upstream_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
340
+ client_response: JSON.stringify({ statusCode: attempt.statusCode, body: attempt.responseBody }),
341
+ is_retry: isOriginal ? 0 : 1, original_request_id: isOriginal ? null : logId,
342
+ router_key_id: routerKeyId,
343
+ });
344
+ }
345
+ else {
346
+ const h = isStream
347
+ ? (r.upstreamResponseHeaders ?? {})
348
+ : (r.headers);
349
+ insertSuccessLog(db, apiType, attemptLogId, clientModel, provider, isStream, startTime, reqBodyStr, clientReq, upstreamReqBase, r.statusCode, attempt.responseBody, h, h, !isOriginal, isOriginal ? null : logId, routerKeyId);
350
+ lastSuccessLogId = attemptLogId;
351
+ }
352
+ }
353
+ // 将最终结果发送给客户端
354
+ if (isStream) {
355
+ if (r.statusCode !== UPSTREAM_SUCCESS) {
356
+ for (const [k, v] of Object.entries(r.upstreamResponseHeaders ?? {}))
357
+ reply.header(k, v);
358
+ reply.status(r.statusCode).send(r.responseBody);
359
+ }
360
+ }
361
+ else {
362
+ const pr = r;
363
+ for (const [k, v] of Object.entries(pr.headers))
364
+ reply.header(k, v);
365
+ return reply.status(pr.statusCode).send(pr.body);
366
+ }
367
+ // 仅对最终成功请求采集 metrics
368
+ if (r.statusCode === UPSTREAM_SUCCESS) {
369
+ if (isStream) {
370
+ const streamResult = r;
371
+ if (streamResult.metricsResult) {
372
+ try {
373
+ insertMetrics(db, { ...streamResult.metricsResult, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
374
+ }
375
+ catch (err) {
376
+ request.log.error({ err }, "Failed to insert metrics");
377
+ }
378
+ }
379
+ }
380
+ else {
381
+ try {
382
+ const mr = MetricsExtractor.fromNonStreamResponse(apiType, r.body);
383
+ if (mr)
384
+ insertMetrics(db, { ...mr, request_log_id: lastSuccessLogId, provider_id: provider.id, backend_model: resolved.backend_model, api_type: apiType });
385
+ }
386
+ catch (err) {
387
+ request.log.error({ err }, "Failed to insert metrics");
388
+ }
389
+ }
390
+ }
391
+ return reply;
392
+ }
393
+ catch (err) {
394
+ const errMsg = err instanceof Error ? err.message : String(err);
395
+ const sentH = buildUpstreamHeaders(cliHdrs, apiKey, Buffer.byteLength(reqBodyStr));
396
+ const upstreamReq = JSON.stringify({ url: `${provider.base_url}${upstreamPath}`, headers: sentH, body: reqBodyStr });
397
+ insertRequestLog(db, {
398
+ id: logId, api_type: apiType, model: clientModel, provider_id: provider.id,
399
+ status_code: HTTP_BAD_GATEWAY, latency_ms: Date.now() - startTime,
400
+ is_stream: isStream ? 1 : 0, error_message: errMsg || "Upstream connection failed",
401
+ created_at: new Date().toISOString(), request_body: reqBodyStr,
402
+ client_request: clientReq, upstream_request: upstreamReq,
403
+ router_key_id: routerKeyId,
404
+ });
405
+ const e = errors.upstreamConnectionFailed();
406
+ return reply.status(e.statusCode).send(e.body);
407
+ }
408
+ }
@@ -0,0 +1,9 @@
1
+ import Database from "better-sqlite3";
2
+ import { type RetryRule } from "../db/index.js";
3
+ export declare class RetryRuleMatcher {
4
+ private cache;
5
+ private raw;
6
+ load(db: Database.Database): void;
7
+ match(statusCode: number, body: string): RetryRule | null;
8
+ test(statusCode: number, body: string): boolean;
9
+ }
@@ -0,0 +1,27 @@
1
+ import { getActiveRetryRules } from "../db/index.js";
2
+ export class RetryRuleMatcher {
3
+ cache = new Map();
4
+ raw = [];
5
+ load(db) {
6
+ this.raw = getActiveRetryRules(db);
7
+ this.cache.clear();
8
+ for (const rule of this.raw) {
9
+ const entries = this.cache.get(rule.status_code) ?? [];
10
+ entries.push({ rule, pattern: new RegExp(rule.body_pattern) });
11
+ this.cache.set(rule.status_code, entries);
12
+ }
13
+ }
14
+ match(statusCode, body) {
15
+ const entries = this.cache.get(statusCode);
16
+ if (!entries)
17
+ return null;
18
+ for (const { rule, pattern } of entries) {
19
+ if (pattern.test(body))
20
+ return rule;
21
+ }
22
+ return null;
23
+ }
24
+ test(statusCode, body) {
25
+ return this.match(statusCode, body) !== null;
26
+ }
27
+ }