llm-simple-router 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/dist/admin/groups.js +25 -0
- package/dist/admin/providers.d.ts +0 -1
- package/dist/admin/providers.js +16 -13
- package/dist/admin/proxy-enhancement.d.ts +7 -0
- package/dist/admin/proxy-enhancement.js +39 -0
- package/dist/admin/router-keys.d.ts +0 -1
- package/dist/admin/router-keys.js +17 -8
- package/dist/admin/routes.d.ts +0 -3
- package/dist/admin/routes.js +9 -4
- package/dist/admin/setup.d.ts +7 -0
- package/dist/admin/setup.js +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +13 -13
- package/dist/db/index.d.ts +5 -2
- package/dist/db/index.js +3 -1
- package/dist/db/logs.d.ts +5 -2
- package/dist/db/logs.js +4 -4
- package/dist/db/mappings.d.ts +16 -0
- package/dist/db/mappings.js +72 -0
- package/dist/db/migrations/014_create_settings.sql +4 -0
- package/dist/db/migrations/015_add_original_model.sql +1 -0
- package/dist/db/migrations/016_create_session_model_tables.sql +24 -0
- package/dist/db/session-states.d.ts +40 -0
- package/dist/db/session-states.js +37 -0
- package/dist/db/settings.d.ts +4 -0
- package/dist/db/settings.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +53 -13
- package/dist/middleware/admin-auth.d.ts +2 -2
- package/dist/middleware/admin-auth.js +21 -8
- package/dist/middleware/auth.js +46 -1
- package/dist/proxy/anthropic.d.ts +0 -1
- package/dist/proxy/anthropic.js +2 -2
- package/dist/proxy/directive-parser.d.ts +7 -0
- package/dist/proxy/directive-parser.js +70 -0
- package/dist/proxy/enhancement-handler.d.ts +23 -0
- package/dist/proxy/enhancement-handler.js +167 -0
- package/dist/proxy/log-helpers.d.ts +41 -0
- package/dist/proxy/log-helpers.js +35 -0
- package/dist/proxy/mapping-resolver.js +39 -2
- package/dist/proxy/model-state.d.ts +28 -0
- package/dist/proxy/model-state.js +111 -0
- package/dist/proxy/openai.d.ts +0 -1
- package/dist/proxy/openai.js +4 -3
- package/dist/proxy/proxy-core.d.ts +9 -47
- package/dist/proxy/proxy-core.js +215 -344
- package/dist/proxy/response-cleaner.d.ts +5 -0
- package/dist/proxy/response-cleaner.js +60 -0
- package/dist/proxy/strategy/failover.d.ts +1 -1
- package/dist/proxy/strategy/failover.js +10 -2
- package/dist/proxy/strategy/random.d.ts +1 -1
- package/dist/proxy/strategy/random.js +8 -2
- package/dist/proxy/strategy/round-robin.d.ts +2 -1
- package/dist/proxy/strategy/round-robin.js +13 -2
- package/dist/proxy/strategy/targets-rule.d.ts +7 -0
- package/dist/proxy/strategy/targets-rule.js +14 -0
- package/dist/proxy/strategy/types.d.ts +5 -1
- package/dist/proxy/strategy/types.js +3 -0
- package/dist/proxy/upstream-call.d.ts +43 -0
- package/dist/proxy/upstream-call.js +208 -0
- package/dist/utils/password.d.ts +2 -0
- package/dist/utils/password.js +14 -0
- package/package.json +6 -5
- package/.env.example +0 -13
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getSetting } from "../db/settings.js";
|
|
3
|
+
import { getActiveProviderModels, resolveByProviderModel } from "../db/index.js";
|
|
4
|
+
import { resolveMapping } from "./mapping-resolver.js";
|
|
5
|
+
import { parseDirective } from "./directive-parser.js";
|
|
6
|
+
import { modelState } from "./model-state.js";
|
|
7
|
+
import { cleanRouterResponses } from "./response-cleaner.js";
|
|
8
|
+
const MODEL_INFO_TAG_TYPE = "model-info";
|
|
9
|
+
/**
|
|
10
|
+
* 解析 "provider_name/backend_model" 格式,返回对应的 client_model。
|
|
11
|
+
* provider_name 只允许 [a-zA-Z0-9_-],/ 作为分隔符。
|
|
12
|
+
*/
|
|
13
|
+
function resolveProviderModel(db, providerSlashModel) {
|
|
14
|
+
const match = /^([a-zA-Z0-9_-]+)\/(.+)$/.exec(providerSlashModel);
|
|
15
|
+
if (!match)
|
|
16
|
+
return null;
|
|
17
|
+
const resolved = resolveByProviderModel(db, match[1], match[2]);
|
|
18
|
+
return resolved?.client_model ?? null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 在代理转发前应用代理增强逻辑(指令解析 + 会话记忆 + 模型替换 + 命令拦截)。
|
|
22
|
+
* 仅当 proxy_enhancement.claude_code_enabled 开启时生效。
|
|
23
|
+
*/
|
|
24
|
+
export function applyEnhancement(db, request, clientModel, sessionId) {
|
|
25
|
+
const nullResult = { effectiveModel: clientModel, originalModel: null, interceptResponse: null };
|
|
26
|
+
const enhancementRaw = getSetting(db, "proxy_enhancement");
|
|
27
|
+
let enhancement = null;
|
|
28
|
+
try {
|
|
29
|
+
enhancement = enhancementRaw ? JSON.parse(enhancementRaw) : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
request.log.warn("Invalid proxy_enhancement JSON, feature disabled");
|
|
33
|
+
}
|
|
34
|
+
if (enhancement?.claude_code_enabled !== true) {
|
|
35
|
+
return nullResult;
|
|
36
|
+
}
|
|
37
|
+
// 清理历史消息中的 <router-response> 标签
|
|
38
|
+
const cleaned = cleanRouterResponses(request.body);
|
|
39
|
+
request.body.messages = cleaned.messages;
|
|
40
|
+
const directive = parseDirective(request.body);
|
|
41
|
+
// 命令拦截:select-model → 返回可用模型列表
|
|
42
|
+
if (directive.isCommandMessage && directive.command?.startsWith("select-model")) {
|
|
43
|
+
const routerKeyId = request.routerKey?.id ?? null;
|
|
44
|
+
const parts = directive.command.trim().split(/\s+/);
|
|
45
|
+
const arg = parts.length > 1 ? parts.slice(1).join(" ") : null;
|
|
46
|
+
// 带参数:设置模型并返回确认
|
|
47
|
+
if (arg && arg !== "") {
|
|
48
|
+
const resolvedClientModel = resolveProviderModel(db, arg);
|
|
49
|
+
if (!resolvedClientModel) {
|
|
50
|
+
return {
|
|
51
|
+
effectiveModel: clientModel,
|
|
52
|
+
originalModel: null,
|
|
53
|
+
interceptResponse: {
|
|
54
|
+
...buildTextResponse("error", `未找到模型: ${arg}`),
|
|
55
|
+
meta: { action: "模型选择失败", detail: arg },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
modelState.set(routerKeyId, arg, sessionId, clientModel, "command");
|
|
60
|
+
return {
|
|
61
|
+
effectiveModel: arg,
|
|
62
|
+
originalModel: null,
|
|
63
|
+
interceptResponse: {
|
|
64
|
+
...buildSelectModelResponse(db, request.routerKey?.allowed_models ?? null, arg),
|
|
65
|
+
meta: { action: "模型选择", detail: arg },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// 无参数:返回模型列表
|
|
70
|
+
return {
|
|
71
|
+
effectiveModel: clientModel,
|
|
72
|
+
originalModel: null,
|
|
73
|
+
interceptResponse: {
|
|
74
|
+
...buildSelectModelResponse(db, request.routerKey?.allowed_models ?? null),
|
|
75
|
+
meta: { action: "模型列表" },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (directive.modelName) {
|
|
80
|
+
// 内联模型指令 → resolveMapping 验证(client_model 格式)
|
|
81
|
+
const resolvedDirective = resolveMapping(db, directive.modelName, { now: new Date() });
|
|
82
|
+
if (resolvedDirective) {
|
|
83
|
+
modelState.set(request.routerKey?.id ?? null, directive.modelName, sessionId, clientModel, "directive");
|
|
84
|
+
request.body.messages = directive.cleanedBody.messages;
|
|
85
|
+
return { effectiveModel: directive.modelName, originalModel: clientModel, interceptResponse: null };
|
|
86
|
+
}
|
|
87
|
+
// 映射失败时保留原始请求(降级策略)
|
|
88
|
+
return nullResult;
|
|
89
|
+
}
|
|
90
|
+
// 无指令 → 查询会话记忆
|
|
91
|
+
const remembered = modelState.get(request.routerKey?.id ?? null, sessionId);
|
|
92
|
+
if (remembered) {
|
|
93
|
+
// 优先尝试 provider_name/backend_model 格式(select-model 命令存储)
|
|
94
|
+
// 直接保留该格式,resolveMapping 会解析出 provider + model
|
|
95
|
+
const providerResolved = resolveProviderModel(db, remembered);
|
|
96
|
+
if (providerResolved) {
|
|
97
|
+
return { effectiveModel: remembered, originalModel: clientModel, interceptResponse: null };
|
|
98
|
+
}
|
|
99
|
+
// 回退到 client_model 格式(内联指令存储)
|
|
100
|
+
const resolvedRemembered = resolveMapping(db, remembered, { now: new Date() });
|
|
101
|
+
if (resolvedRemembered) {
|
|
102
|
+
return { effectiveModel: remembered, originalModel: clientModel, interceptResponse: null };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return nullResult;
|
|
106
|
+
}
|
|
107
|
+
/** 构造 Anthropic 格式的 router 文本响应 */
|
|
108
|
+
function buildTextResponse(type, inner) {
|
|
109
|
+
const text = `<router-response type="${type}">${inner}</router-response>`;
|
|
110
|
+
const body = {
|
|
111
|
+
id: `msg-${randomUUID()}`,
|
|
112
|
+
type: "message",
|
|
113
|
+
role: "assistant",
|
|
114
|
+
content: [{ type: "text", text }],
|
|
115
|
+
model: "router",
|
|
116
|
+
stop_reason: "end_turn",
|
|
117
|
+
stop_sequence: null,
|
|
118
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
119
|
+
};
|
|
120
|
+
return { statusCode: 200, body };
|
|
121
|
+
}
|
|
122
|
+
/** 查询所有可用的 provider_model 并构造响应 */
|
|
123
|
+
function buildSelectModelResponse(db, allowedModelsRaw, selectedModel) {
|
|
124
|
+
const providerModels = getActiveProviderModels(db);
|
|
125
|
+
// 按 allowed_models 过滤(allowed_models 存储的是 client_model 列表)
|
|
126
|
+
let allowedSet = null;
|
|
127
|
+
if (allowedModelsRaw) {
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(allowedModelsRaw).filter((s) => s.trim() !== "");
|
|
130
|
+
if (parsed.length > 0)
|
|
131
|
+
allowedSet = new Set(parsed);
|
|
132
|
+
}
|
|
133
|
+
catch { /* 忽略解析失败 */ }
|
|
134
|
+
}
|
|
135
|
+
const filtered = allowedSet
|
|
136
|
+
? providerModels.filter(m => allowedSet.has(m.backend_model))
|
|
137
|
+
: providerModels;
|
|
138
|
+
// 去重并格式化为 "provider_name/backend_model"
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const displayModels = [];
|
|
141
|
+
for (const m of filtered) {
|
|
142
|
+
const key = `${m.provider_name}/${m.backend_model}`;
|
|
143
|
+
if (!seen.has(key)) {
|
|
144
|
+
seen.add(key);
|
|
145
|
+
displayModels.push(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let inner;
|
|
149
|
+
let responseType;
|
|
150
|
+
if (selectedModel) {
|
|
151
|
+
inner = `已选择模型: ${selectedModel}`;
|
|
152
|
+
responseType = "model-selected";
|
|
153
|
+
}
|
|
154
|
+
else if (displayModels.length > 0) {
|
|
155
|
+
inner = displayModels.map((m, i) => `${i + 1}. ${m}`).join("\n");
|
|
156
|
+
responseType = "model-list";
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
inner = "(无可用模型)";
|
|
160
|
+
responseType = "model-list";
|
|
161
|
+
}
|
|
162
|
+
return buildTextResponse(responseType, inner);
|
|
163
|
+
}
|
|
164
|
+
/** 生成注入到非流式响应中的模型信息标签 */
|
|
165
|
+
export function buildModelInfoTag(effectiveModel) {
|
|
166
|
+
return `<router-response type="${MODEL_INFO_TAG_TYPE}">当前模型: ${effectiveModel}</router-response>`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { Provider } from "../db/index.js";
|
|
3
|
+
import type { RawHeaders } from "./proxy-core.js";
|
|
4
|
+
export interface RequestLogParams {
|
|
5
|
+
id: string;
|
|
6
|
+
apiType: string;
|
|
7
|
+
model: string;
|
|
8
|
+
provider: Provider;
|
|
9
|
+
isStream: boolean;
|
|
10
|
+
startTime: number;
|
|
11
|
+
reqBody: string;
|
|
12
|
+
clientReq: string;
|
|
13
|
+
upstreamReq: string;
|
|
14
|
+
status: number;
|
|
15
|
+
respBody: string | null;
|
|
16
|
+
upHdrs: Record<string, string>;
|
|
17
|
+
cliHdrs: Record<string, string>;
|
|
18
|
+
isRetry?: boolean;
|
|
19
|
+
originalRequestId?: string | null;
|
|
20
|
+
routerKeyId?: string | null;
|
|
21
|
+
originalModel?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** 插入成功请求日志,供 openai/anthropic 插件共享 */
|
|
24
|
+
export declare function insertSuccessLog(db: Database.Database, params: RequestLogParams): void;
|
|
25
|
+
export interface RejectedLogParams {
|
|
26
|
+
db: Database.Database;
|
|
27
|
+
logId: string;
|
|
28
|
+
apiType: string;
|
|
29
|
+
model: string;
|
|
30
|
+
statusCode: number;
|
|
31
|
+
errorMessage: string;
|
|
32
|
+
startTime: number;
|
|
33
|
+
isStream: boolean;
|
|
34
|
+
routerKeyId: string | null;
|
|
35
|
+
originalBody: Record<string, unknown>;
|
|
36
|
+
clientHeaders: RawHeaders;
|
|
37
|
+
providerId?: string | null;
|
|
38
|
+
originalModel?: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** Log a request rejected before reaching upstream */
|
|
41
|
+
export declare function insertRejectedLog(params: RejectedLogParams): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { insertRequestLog } from "../db/index.js";
|
|
2
|
+
/** 插入成功请求日志,供 openai/anthropic 插件共享 */
|
|
3
|
+
export function insertSuccessLog(db, params) {
|
|
4
|
+
const { id: logId, apiType, model, provider, isStream, startTime, reqBody, clientReq, upstreamReq, status, respBody, upHdrs, cliHdrs, isRetry = false, originalRequestId = null, routerKeyId = null, originalModel = null } = params;
|
|
5
|
+
insertRequestLog(db, {
|
|
6
|
+
id: logId, api_type: apiType, model, provider_id: provider.id,
|
|
7
|
+
status_code: status, latency_ms: Date.now() - startTime,
|
|
8
|
+
is_stream: isStream ? 1 : 0, error_message: null,
|
|
9
|
+
created_at: new Date().toISOString(), request_body: reqBody,
|
|
10
|
+
response_body: respBody, client_request: clientReq, upstream_request: upstreamReq,
|
|
11
|
+
upstream_response: JSON.stringify({ statusCode: status, headers: upHdrs, body: respBody }),
|
|
12
|
+
client_response: JSON.stringify({ statusCode: status, headers: cliHdrs, body: respBody }),
|
|
13
|
+
is_retry: isRetry ? 1 : 0, original_request_id: originalRequestId,
|
|
14
|
+
router_key_id: routerKeyId, original_model: originalModel,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Log a request rejected before reaching upstream */
|
|
18
|
+
export function insertRejectedLog(params) {
|
|
19
|
+
const { db, logId, apiType, model, statusCode, errorMessage, startTime, isStream, routerKeyId, originalBody, clientHeaders, providerId = null, originalModel = null } = params;
|
|
20
|
+
insertRequestLog(db, {
|
|
21
|
+
id: logId,
|
|
22
|
+
api_type: apiType,
|
|
23
|
+
model,
|
|
24
|
+
provider_id: providerId,
|
|
25
|
+
status_code: statusCode,
|
|
26
|
+
latency_ms: Date.now() - startTime,
|
|
27
|
+
is_stream: isStream ? 1 : 0,
|
|
28
|
+
error_message: errorMessage,
|
|
29
|
+
created_at: new Date().toISOString(),
|
|
30
|
+
request_body: JSON.stringify(originalBody),
|
|
31
|
+
client_request: JSON.stringify({ headers: clientHeaders, body: originalBody }),
|
|
32
|
+
router_key_id: routerKeyId,
|
|
33
|
+
original_model: originalModel,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { STRATEGY_NAMES } from "./strategy/types.js";
|
|
2
2
|
import { ScheduledStrategy } from "./strategy/scheduled.js";
|
|
3
|
+
import { RoundRobinStrategy } from "./strategy/round-robin.js";
|
|
4
|
+
import { RandomStrategy } from "./strategy/random.js";
|
|
5
|
+
import { FailoverStrategy } from "./strategy/failover.js";
|
|
3
6
|
import { getMappingGroup } from "../db/index.js";
|
|
4
7
|
// 策略注册表:key 为数据库中 mapping_groups.strategy 字段的值。
|
|
5
8
|
// 新增策略时:
|
|
@@ -7,11 +10,45 @@ import { getMappingGroup } from "../db/index.js";
|
|
|
7
10
|
// 2. 在此注册表中添加映射
|
|
8
11
|
const STRATEGIES = {
|
|
9
12
|
[STRATEGY_NAMES.SCHEDULED]: new ScheduledStrategy(),
|
|
13
|
+
[STRATEGY_NAMES.ROUND_ROBIN]: new RoundRobinStrategy(),
|
|
14
|
+
[STRATEGY_NAMES.RANDOM]: new RandomStrategy(),
|
|
15
|
+
[STRATEGY_NAMES.FAILOVER]: new FailoverStrategy(),
|
|
10
16
|
};
|
|
11
17
|
export function resolveMapping(db, clientModel, context) {
|
|
18
|
+
// 优先处理 provider_name/backend_model 格式(如 kimi-coding-plan/kimi-for-coding)
|
|
19
|
+
// 这种格式直接路由到指定 provider,不需要 mapping group
|
|
20
|
+
const slashMatch = /^([a-zA-Z0-9_-]+)\/(.+)$/.exec(clientModel);
|
|
21
|
+
if (slashMatch) {
|
|
22
|
+
const providerName = slashMatch[1];
|
|
23
|
+
const backendModel = slashMatch[2];
|
|
24
|
+
const provider = db.prepare("SELECT id, models FROM providers WHERE name = ? AND is_active = 1").get(providerName);
|
|
25
|
+
if (provider) {
|
|
26
|
+
try {
|
|
27
|
+
const models = JSON.parse(provider.models);
|
|
28
|
+
if (models.includes(backendModel)) {
|
|
29
|
+
return { backend_model: backendModel, provider_id: provider.id };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { /* 忽略解析失败 */ }
|
|
33
|
+
}
|
|
34
|
+
// 明确的 provider/model 格式解析失败,不再 fallback 到 mapping group
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
12
37
|
const group = getMappingGroup(db, clientModel);
|
|
13
|
-
if (!group)
|
|
38
|
+
if (!group) {
|
|
39
|
+
// Fallback: 没有 mapping group 时,直接查 provider 的 models 字段
|
|
40
|
+
const providers = db.prepare("SELECT id, models FROM providers WHERE is_active = 1").all();
|
|
41
|
+
for (const p of providers) {
|
|
42
|
+
try {
|
|
43
|
+
const models = JSON.parse(p.models);
|
|
44
|
+
if (models.includes(clientModel)) {
|
|
45
|
+
return { backend_model: clientModel, provider_id: p.id };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* 忽略解析失败 */ }
|
|
49
|
+
}
|
|
14
50
|
return null;
|
|
51
|
+
}
|
|
15
52
|
let rule;
|
|
16
53
|
try {
|
|
17
54
|
rule = JSON.parse(group.rule);
|
|
@@ -23,5 +60,5 @@ export function resolveMapping(db, clientModel, context) {
|
|
|
23
60
|
const strategy = STRATEGIES[group.strategy];
|
|
24
61
|
if (!strategy)
|
|
25
62
|
return null;
|
|
26
|
-
return strategy.select(rule, context) ?? null;
|
|
63
|
+
return strategy.select(rule, context, clientModel) ?? null;
|
|
27
64
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
export declare class ModelStateManager {
|
|
3
|
+
private store;
|
|
4
|
+
private db;
|
|
5
|
+
/** 单例注入 DB 实例,启动时调用一次 */
|
|
6
|
+
init(db: Database.Database): void;
|
|
7
|
+
/** 构造内存 Map 的 key:有 sessionId 时用复合键 */
|
|
8
|
+
buildKey(routerKeyId: string | null, sessionId?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* 写入模型状态。
|
|
11
|
+
* - 始终写内存
|
|
12
|
+
* - 有 sessionId 时双写 DB(事务:upsert + history)
|
|
13
|
+
* - model="default" 时删除记忆
|
|
14
|
+
*/
|
|
15
|
+
set(routerKeyId: string | null, model: string, sessionId?: string, originalModel?: string, triggerType?: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* 读取模型状态。
|
|
18
|
+
* - 先查内存,命中且未过期直接返回
|
|
19
|
+
* - 未命中且有 sessionId 时查 DB 并回填内存
|
|
20
|
+
*/
|
|
21
|
+
get(routerKeyId: string | null, sessionId?: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* 删除模型状态。
|
|
24
|
+
* - 同时清除内存和 DB
|
|
25
|
+
*/
|
|
26
|
+
delete(routerKeyId: string, sessionId: string): void;
|
|
27
|
+
}
|
|
28
|
+
export declare const modelState: ModelStateManager;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { upsertSessionState, getSessionState, deleteSessionState, insertSessionHistory, } from "../db/session-states.js";
|
|
2
|
+
// 会话记忆在 24 小时后过期(滑动窗口)
|
|
3
|
+
const HOUR_MS = 3600_000;
|
|
4
|
+
const TTL_HOURS = 24;
|
|
5
|
+
const TTL_MS = TTL_HOURS * HOUR_MS;
|
|
6
|
+
export class ModelStateManager {
|
|
7
|
+
store = new Map();
|
|
8
|
+
db = null;
|
|
9
|
+
/** 单例注入 DB 实例,启动时调用一次 */
|
|
10
|
+
init(db) {
|
|
11
|
+
this.db = db;
|
|
12
|
+
}
|
|
13
|
+
/** 构造内存 Map 的 key:有 sessionId 时用复合键 */
|
|
14
|
+
buildKey(routerKeyId, sessionId) {
|
|
15
|
+
if (sessionId) {
|
|
16
|
+
return `${routerKeyId ?? "null"}:${sessionId}`;
|
|
17
|
+
}
|
|
18
|
+
return String(routerKeyId);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 写入模型状态。
|
|
22
|
+
* - 始终写内存
|
|
23
|
+
* - 有 sessionId 时双写 DB(事务:upsert + history)
|
|
24
|
+
* - model="default" 时删除记忆
|
|
25
|
+
*/
|
|
26
|
+
set(routerKeyId, model, sessionId, originalModel, triggerType) {
|
|
27
|
+
const key = this.buildKey(routerKeyId, sessionId);
|
|
28
|
+
if (model === "default") {
|
|
29
|
+
this.store.delete(key);
|
|
30
|
+
if (sessionId && routerKeyId && this.db) {
|
|
31
|
+
this.db.transaction(() => {
|
|
32
|
+
deleteSessionState(this.db, routerKeyId, sessionId);
|
|
33
|
+
insertSessionHistory(this.db, {
|
|
34
|
+
router_key_id: routerKeyId,
|
|
35
|
+
session_id: sessionId,
|
|
36
|
+
old_model: null,
|
|
37
|
+
new_model: "default",
|
|
38
|
+
trigger_type: triggerType ?? "command",
|
|
39
|
+
});
|
|
40
|
+
})();
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.store.set(key, { model, updatedAt: Date.now() });
|
|
45
|
+
if (sessionId && routerKeyId && this.db) {
|
|
46
|
+
this.db.transaction(() => {
|
|
47
|
+
upsertSessionState(this.db, {
|
|
48
|
+
router_key_id: routerKeyId,
|
|
49
|
+
session_id: sessionId,
|
|
50
|
+
current_model: model,
|
|
51
|
+
original_model: originalModel ?? null,
|
|
52
|
+
});
|
|
53
|
+
insertSessionHistory(this.db, {
|
|
54
|
+
router_key_id: routerKeyId,
|
|
55
|
+
session_id: sessionId,
|
|
56
|
+
old_model: null,
|
|
57
|
+
new_model: model,
|
|
58
|
+
trigger_type: triggerType ?? "command",
|
|
59
|
+
});
|
|
60
|
+
})();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 读取模型状态。
|
|
65
|
+
* - 先查内存,命中且未过期直接返回
|
|
66
|
+
* - 未命中且有 sessionId 时查 DB 并回填内存
|
|
67
|
+
*/
|
|
68
|
+
get(routerKeyId, sessionId) {
|
|
69
|
+
const key = this.buildKey(routerKeyId, sessionId);
|
|
70
|
+
const entry = this.store.get(key);
|
|
71
|
+
// 内存命中
|
|
72
|
+
if (entry) {
|
|
73
|
+
if (Date.now() - entry.updatedAt > TTL_MS) {
|
|
74
|
+
this.store.delete(key);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return entry.model;
|
|
78
|
+
}
|
|
79
|
+
// 内存未命中,尝试从 DB 回填
|
|
80
|
+
if (sessionId && routerKeyId && this.db) {
|
|
81
|
+
const row = getSessionState(this.db, routerKeyId, sessionId);
|
|
82
|
+
if (row) {
|
|
83
|
+
this.store.set(key, { model: row.current_model, updatedAt: Date.now() });
|
|
84
|
+
return row.current_model;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 删除模型状态。
|
|
91
|
+
* - 同时清除内存和 DB
|
|
92
|
+
*/
|
|
93
|
+
delete(routerKeyId, sessionId) {
|
|
94
|
+
const key = this.buildKey(routerKeyId, sessionId);
|
|
95
|
+
this.store.delete(key);
|
|
96
|
+
if (this.db) {
|
|
97
|
+
this.db.transaction(() => {
|
|
98
|
+
deleteSessionState(this.db, routerKeyId, sessionId);
|
|
99
|
+
insertSessionHistory(this.db, {
|
|
100
|
+
router_key_id: routerKeyId,
|
|
101
|
+
session_id: sessionId,
|
|
102
|
+
old_model: null,
|
|
103
|
+
new_model: "default",
|
|
104
|
+
trigger_type: "manual_clear",
|
|
105
|
+
});
|
|
106
|
+
})();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// singleton
|
|
111
|
+
export const modelState = new ModelStateManager();
|
package/dist/proxy/openai.d.ts
CHANGED
package/dist/proxy/openai.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fp from "fastify-plugin";
|
|
2
2
|
import { getActiveProviders } from "../db/index.js";
|
|
3
|
+
import { getSetting } from "../db/settings.js";
|
|
3
4
|
import { decrypt } from "../utils/crypto.js";
|
|
4
5
|
import { proxyGetRequest, handleProxyPost, } from "./proxy-core.js";
|
|
5
6
|
const HTTP_NOT_FOUND = 404;
|
|
@@ -32,9 +33,9 @@ function sendError(reply, e) {
|
|
|
32
33
|
return reply.status(e.statusCode).send(e.body);
|
|
33
34
|
}
|
|
34
35
|
const openaiProxyRaw = (app, opts, done) => {
|
|
35
|
-
const { db,
|
|
36
|
+
const { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher } = opts;
|
|
36
37
|
app.post(CHAT_COMPLETIONS_PATH, async (request, reply) => {
|
|
37
|
-
const deps = { db,
|
|
38
|
+
const deps = { db, streamTimeoutMs, retryMaxAttempts, retryBaseDelayMs, matcher };
|
|
38
39
|
return handleProxyPost(request, reply, "openai", CHAT_COMPLETIONS_PATH, openaiErrors, deps, {
|
|
39
40
|
beforeSendProxy: (body, isStream) => {
|
|
40
41
|
if (isStream && !body.stream_options) {
|
|
@@ -51,7 +52,7 @@ const openaiProxyRaw = (app, opts, done) => {
|
|
|
51
52
|
body: { error: { message: "No active OpenAI provider configured", type: "invalid_request_error", code: "no_provider" } },
|
|
52
53
|
});
|
|
53
54
|
const provider = providers[0];
|
|
54
|
-
const apiKey = decrypt(provider.api_key,
|
|
55
|
+
const apiKey = decrypt(provider.api_key, getSetting(db, "encryption_key"));
|
|
55
56
|
const cliHdrs = request.headers;
|
|
56
57
|
try {
|
|
57
58
|
const result = await proxyGetRequest(provider, apiKey, cliHdrs, MODELS_PATH);
|
|
@@ -1,52 +1,9 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
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
4
|
import type { RetryRuleMatcher } from "./retry-rules.js";
|
|
5
|
+
import { type ProxyResult, type StreamProxyResult, type GetProxyResult } from "./upstream-call.js";
|
|
7
6
|
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
7
|
export interface ProxyErrorResponse {
|
|
51
8
|
statusCode: number;
|
|
52
9
|
body: unknown;
|
|
@@ -60,15 +17,20 @@ export interface ProxyErrorFormatter {
|
|
|
60
17
|
}
|
|
61
18
|
export interface ProxyHandlerDeps {
|
|
62
19
|
db: Database.Database;
|
|
63
|
-
encryptionKey: string;
|
|
64
20
|
streamTimeoutMs: number;
|
|
65
21
|
retryMaxAttempts: number;
|
|
66
22
|
retryBaseDelayMs: number;
|
|
67
23
|
matcher?: RetryRuleMatcher;
|
|
68
24
|
}
|
|
25
|
+
export type { ProxyResult, StreamProxyResult, GetProxyResult };
|
|
26
|
+
export declare const SKIP_UPSTREAM: Set<string>;
|
|
27
|
+
export declare function selectHeaders(raw: RawHeaders, skip: Set<string>): Record<string, string>;
|
|
28
|
+
export declare function buildUpstreamHeaders(clientHeaders: RawHeaders, apiKey: string, payloadBytes?: number): Record<string, string>;
|
|
29
|
+
export declare function proxyGetRequest(backend: Provider, apiKey: string, clientHeaders: RawHeaders, upstreamPath: string): Promise<GetProxyResult>;
|
|
69
30
|
/**
|
|
70
|
-
* 共享 POST handler,参数化 apiType/errorFormat/upstreamPath
|
|
71
|
-
*
|
|
31
|
+
* 共享 POST handler,参数化 apiType/errorFormat/upstreamPath 等差异。
|
|
32
|
+
* 当分组策略为 failover 时,在 while 循环中依次尝试不同 target,
|
|
33
|
+
* 直到成功(或 headers 已发送)才返回。
|
|
72
34
|
*/
|
|
73
35
|
export declare function handleProxyPost(request: FastifyRequest, reply: FastifyReply, apiType: "openai" | "anthropic", upstreamPath: string, errors: ProxyErrorFormatter, deps: ProxyHandlerDeps, options?: {
|
|
74
36
|
beforeSendProxy?: (body: Record<string, unknown>, isStream: boolean) => void;
|