llm-simple-router 0.5.2 → 0.5.4
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/config/recommended-providers.json +234 -19
- package/dist/admin/api-response.d.ts +0 -1
- package/dist/admin/api-response.js +8 -4
- package/dist/admin/groups.js +35 -0
- package/dist/admin/monitor.js +2 -0
- package/dist/admin/providers.js +188 -22
- package/dist/admin/proxy-enhancement.js +9 -9
- package/dist/config/model-context.d.ts +10 -0
- package/dist/config/model-context.js +105 -0
- package/dist/db/index.d.ts +3 -1
- package/dist/db/index.js +2 -1
- package/dist/db/logs.d.ts +4 -0
- package/dist/db/logs.js +7 -3
- package/dist/db/mappings.d.ts +2 -1
- package/dist/db/mappings.js +2 -2
- package/dist/db/migrations/023_create_provider_model_info.sql +8 -0
- package/dist/db/migrations/024_add_mapping_groups_is_active.sql +1 -0
- package/dist/db/migrations/025_add_client_status_code.sql +3 -0
- package/dist/db/model-info.d.ts +14 -0
- package/dist/db/model-info.js +27 -0
- package/dist/db/providers.d.ts +1 -0
- package/dist/db/providers.js +1 -1
- package/dist/index.js +15 -3
- package/dist/middleware/auth.js +1 -1
- package/dist/monitor/request-tracker.d.ts +2 -0
- package/dist/monitor/request-tracker.js +18 -0
- package/dist/proxy/anthropic.js +13 -0
- package/dist/proxy/enhancement/directive-parser.d.ts +8 -2
- package/dist/proxy/enhancement/directive-parser.js +44 -17
- package/dist/proxy/enhancement/enhancement-handler.js +184 -54
- package/dist/proxy/enhancement/index.d.ts +1 -1
- package/dist/proxy/enhancement/index.js +1 -1
- package/dist/proxy/enhancement-config.d.ts +6 -0
- package/dist/proxy/enhancement-config.js +19 -0
- package/dist/proxy/openai.js +40 -3
- package/dist/proxy/overflow.d.ts +18 -0
- package/dist/proxy/overflow.js +128 -0
- package/dist/proxy/patch/deepseek/index.d.ts +6 -0
- package/dist/proxy/patch/deepseek/index.js +11 -0
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +12 -0
- package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +90 -0
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +6 -0
- package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +24 -0
- package/dist/proxy/patch/index.d.ts +9 -0
- package/dist/proxy/patch/index.js +17 -0
- package/dist/proxy/proxy-core.d.ts +9 -2
- package/dist/proxy/proxy-core.js +24 -2
- package/dist/proxy/proxy-handler.js +34 -9
- package/dist/proxy/proxy-logging.js +23 -2
- package/dist/proxy/resilience.d.ts +4 -0
- package/dist/proxy/resilience.js +8 -1
- package/dist/proxy/strategy/types.d.ts +2 -0
- package/dist/proxy/stream-proxy.js +2 -1
- package/dist/proxy/transport-fn.js +3 -2
- package/dist/proxy/transport.js +3 -2
- package/dist/proxy/types.d.ts +3 -1
- package/dist/proxy/types.js +5 -1
- package/dist/upgrade/checker.js +5 -2
- package/dist/utils/time-range.js +28 -13
- package/frontend-dist/assets/CardContent-GNY_j_L3.js +1 -0
- package/frontend-dist/assets/CardTitle-BhXJbSoh.js +1 -0
- package/frontend-dist/assets/Checkbox-n_sh6Lvx.js +1 -0
- package/frontend-dist/assets/CollapsibleTrigger-DDCUOXDR.js +1 -0
- package/frontend-dist/assets/Collection-DbtqQ1jF.js +1 -0
- package/frontend-dist/assets/Dashboard-Dy9frcgO.js +3 -0
- package/frontend-dist/assets/DialogTitle-BEWUnuJQ.js +1 -0
- package/frontend-dist/assets/{Input-O0ebU-Va.js → Input-CmibY9Fx.js} +1 -1
- package/frontend-dist/assets/Label-Cs__wFH0.js +1 -0
- package/frontend-dist/assets/Login-BciEc1TW.js +1 -0
- package/frontend-dist/assets/Logs-BkqwWW0-.js +1 -0
- package/frontend-dist/assets/ModelMappings-DrCJ_TCf.js +1 -0
- package/frontend-dist/assets/Monitor-C-b4qyuI.js +1 -0
- package/frontend-dist/assets/PopoverTrigger-DaKOMSVs.js +1 -0
- package/frontend-dist/assets/PopperContent-DZ6plcjf.js +1 -0
- package/frontend-dist/assets/Providers-u8utX74M.js +1 -0
- package/frontend-dist/assets/ProxyEnhancement-8_xhndGt.js +5 -0
- package/frontend-dist/assets/RetryRules-D1psYDEP.js +1 -0
- package/frontend-dist/assets/RouterKeys-ovPFGhjy.js +1 -0
- package/frontend-dist/assets/RovingFocusItem-Dsv9AkP7.js +1 -0
- package/frontend-dist/assets/SelectValue-BoUWfZAg.js +1 -0
- package/frontend-dist/assets/Settings-DXF-6A8C.js +6 -0
- package/frontend-dist/assets/Setup-rVLqiz0d.js +1 -0
- package/frontend-dist/assets/Switch-po5ZVBE3.js +1 -0
- package/frontend-dist/assets/TableHeader-Zyvq_0p2.js +1 -0
- package/frontend-dist/assets/{TabsTrigger-CPCi2HIa.js → TabsTrigger-CgDhZGkT.js} +1 -1
- package/frontend-dist/assets/Teleport-CgTHarey.js +3 -0
- package/frontend-dist/assets/TooltipTrigger-C2qO21dQ.js +1 -0
- package/frontend-dist/assets/UnifiedRequestDialog-Dksad8eN.js +3 -0
- package/frontend-dist/assets/{VisuallyHidden-Cyk-jWwh.js → VisuallyHidden-fbPmoMwi.js} +1 -1
- package/frontend-dist/assets/VisuallyHiddenInput-7j8wkPrW.js +1 -0
- package/frontend-dist/assets/alert-dialog-DbT3PzoF.js +1 -0
- package/frontend-dist/assets/badge-BVxnlnsH.js +1 -0
- package/frontend-dist/assets/{button-BQ3s7yNh.js → button-BCrIpNwA.js} +2 -2
- package/frontend-dist/assets/chevron-down-CWBwGxSp.js +1 -0
- package/frontend-dist/assets/circle-question-mark-DRkkqjgG.js +1 -0
- package/frontend-dist/assets/dialog-BNlCZpHK.js +1 -0
- package/frontend-dist/assets/file-text-BavS6SrF.js +1 -0
- package/frontend-dist/assets/format-K3VR67cG.js +1 -0
- package/frontend-dist/assets/index-BP4imfye.css +1 -0
- package/frontend-dist/assets/index-DrBJPq6d.js +1 -0
- package/frontend-dist/assets/lib-CGpNhf06.js +1 -0
- package/frontend-dist/assets/loader-circle-Cpd89XQ7.js +1 -0
- package/frontend-dist/assets/ohash.D__AXeF1-DkJnWU8a.js +1 -0
- package/frontend-dist/assets/{useClipboard-Cnnz6AAN.js → useClipboard-Bq8yZunx.js} +1 -1
- package/frontend-dist/assets/useLogRetention-BWPm3G_A.js +1 -0
- package/frontend-dist/assets/useNonce-D5lpSPNk.js +1 -0
- package/frontend-dist/assets/x-BFIp7DLt.js +1 -0
- package/frontend-dist/index.html +20 -17
- package/package.json +2 -1
- package/frontend-dist/assets/CardContent-WrBnGhTg.js +0 -1
- package/frontend-dist/assets/CardTitle-BcDYk7cq.js +0 -1
- package/frontend-dist/assets/Checkbox-MZf0YsDG.js +0 -1
- package/frontend-dist/assets/CollapsibleTrigger-CrOH9HlW.js +0 -1
- package/frontend-dist/assets/Collection-DcTx_Y54.js +0 -1
- package/frontend-dist/assets/Dashboard-D0oDrSLr.js +0 -3
- package/frontend-dist/assets/DialogTitle-Cl5Cd7QH.js +0 -1
- package/frontend-dist/assets/Label-C_S0y7Um.js +0 -1
- package/frontend-dist/assets/Login-DGY7uF8P.js +0 -1
- package/frontend-dist/assets/Logs-ls8pv89b.js +0 -1
- package/frontend-dist/assets/ModelMappings-DGlf0S4s.js +0 -1
- package/frontend-dist/assets/Monitor-BSI87grz.js +0 -1
- package/frontend-dist/assets/PopperContent-C6Q7hDmf.js +0 -1
- package/frontend-dist/assets/Providers-ZkRpj8_m.js +0 -1
- package/frontend-dist/assets/ProxyEnhancement-DFPI1W6Z.js +0 -5
- package/frontend-dist/assets/RetryRules-DtM31qsl.js +0 -1
- package/frontend-dist/assets/RouterKeys-D63tRFKm.js +0 -1
- package/frontend-dist/assets/RovingFocusItem-BJoylAKU.js +0 -1
- package/frontend-dist/assets/SelectValue-CLp5z6_I.js +0 -1
- package/frontend-dist/assets/Settings-DSgRKbTQ.js +0 -6
- package/frontend-dist/assets/Setup-BDmj6CRk.js +0 -1
- package/frontend-dist/assets/Switch-Wz-t_zkv.js +0 -1
- package/frontend-dist/assets/TableHeader-DGtcqGkw.js +0 -1
- package/frontend-dist/assets/Teleport-DdjYHlNK.js +0 -3
- package/frontend-dist/assets/TooltipTrigger-H_QoPY1n.js +0 -1
- package/frontend-dist/assets/UnifiedRequestDialog-BAAfMJJl.js +0 -3
- package/frontend-dist/assets/VisuallyHiddenInput-CYjNe_H8.js +0 -1
- package/frontend-dist/assets/alert-dialog-Bi3dliLl.js +0 -1
- package/frontend-dist/assets/badge-Kkta3e9W.js +0 -1
- package/frontend-dist/assets/createLucideIcon-D1tkPDOQ.js +0 -1
- package/frontend-dist/assets/dialog-DoIATUYw.js +0 -1
- package/frontend-dist/assets/file-text-Dt6QP1bZ.js +0 -1
- package/frontend-dist/assets/format-DOVIVsQC.js +0 -1
- package/frontend-dist/assets/index-BY0E7CHR.js +0 -1
- package/frontend-dist/assets/index-Bnrh1mFY.css +0 -1
- package/frontend-dist/assets/lib-CxwxnlwW.js +0 -1
- package/frontend-dist/assets/ohash.D__AXeF1-b0PiKZB_.js +0 -1
- package/frontend-dist/assets/useLogRetention-DYP5LOAc.js +0 -1
- package/frontend-dist/assets/useNonce-DKbOCfgM.js +0 -1
- package/frontend-dist/assets/x-CAoitXRt.js +0 -1
package/dist/index.js
CHANGED
|
@@ -40,7 +40,12 @@ export async function buildApp(options) {
|
|
|
40
40
|
shouldBackfill = true;
|
|
41
41
|
}
|
|
42
42
|
const isDev = process.env.NODE_ENV !== "production";
|
|
43
|
+
const MAX_BODY_SIZE_MB = 50;
|
|
44
|
+
const KB = 1024;
|
|
45
|
+
const MB = KB * KB;
|
|
43
46
|
const app = Fastify({
|
|
47
|
+
// Claude Code 图片请求含 base64 编码,单张可达数十 MB
|
|
48
|
+
bodyLimit: MAX_BODY_SIZE_MB * MB,
|
|
44
49
|
logger: {
|
|
45
50
|
level: config.LOG_LEVEL,
|
|
46
51
|
...(isDev
|
|
@@ -71,6 +76,11 @@ export async function buildApp(options) {
|
|
|
71
76
|
.join("; ");
|
|
72
77
|
return new Error(message);
|
|
73
78
|
});
|
|
79
|
+
// 记录请求到达时间,供全局错误处理计算延迟
|
|
80
|
+
app.addHook("onRequest", (request, _reply, done) => {
|
|
81
|
+
request.receivedAt = Date.now();
|
|
82
|
+
done();
|
|
83
|
+
});
|
|
74
84
|
// 统一错误处理:代理路由保持 {error:{message}},Admin API 使用信封格式
|
|
75
85
|
app.setErrorHandler((error, request, reply) => {
|
|
76
86
|
const fastifyError = error;
|
|
@@ -81,17 +91,19 @@ export async function buildApp(options) {
|
|
|
81
91
|
if (proxyApiType) {
|
|
82
92
|
request.log.error({ statusCode: status, err: error }, `Proxy request error: ${fastifyError.message}`);
|
|
83
93
|
const body = request.body;
|
|
94
|
+
const receivedAt = request.receivedAt;
|
|
95
|
+
const latencyMs = receivedAt ? Date.now() - receivedAt : 0;
|
|
84
96
|
insertRequestLog(db, {
|
|
85
97
|
id: randomUUID(),
|
|
86
98
|
api_type: proxyApiType,
|
|
87
99
|
model: body?.model || null,
|
|
88
100
|
provider_id: null,
|
|
89
101
|
status_code: status,
|
|
90
|
-
latency_ms:
|
|
91
|
-
is_stream: 0,
|
|
102
|
+
latency_ms: latencyMs,
|
|
103
|
+
is_stream: body?.stream === true ? 1 : 0,
|
|
92
104
|
error_message: fastifyError.message,
|
|
93
105
|
created_at: new Date().toISOString(),
|
|
94
|
-
client_request: JSON.stringify({ headers: request.headers }),
|
|
106
|
+
client_request: JSON.stringify({ headers: request.headers, ...(body ? { body } : {}) }),
|
|
95
107
|
router_key_id: request.routerKey?.id ?? null,
|
|
96
108
|
});
|
|
97
109
|
}
|
package/dist/middleware/auth.js
CHANGED
|
@@ -31,7 +31,7 @@ function logRejectedAuth(db, apiType, statusCode, errorMessage, request) {
|
|
|
31
31
|
is_stream: 0,
|
|
32
32
|
error_message: errorMessage,
|
|
33
33
|
created_at: new Date().toISOString(),
|
|
34
|
-
client_request: JSON.stringify({ headers: request.headers }),
|
|
34
|
+
client_request: JSON.stringify({ method: request.method, ip: request.ip, headers: request.headers }),
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
const authMiddlewareRaw = (app, options, done) => {
|
|
@@ -41,6 +41,8 @@ export declare class RequestTracker {
|
|
|
41
41
|
getConcurrency(): ProviderConcurrencySnapshot[];
|
|
42
42
|
getRuntime(): RuntimeMetrics;
|
|
43
43
|
addClient(res: ServerResponse): void;
|
|
44
|
+
/** 向单个客户端发送当前活跃请求快照 */
|
|
45
|
+
private sendInitialSnapshot;
|
|
44
46
|
removeClient(res: ServerResponse): void;
|
|
45
47
|
startPushInterval(): void;
|
|
46
48
|
stopPushInterval(): void;
|
|
@@ -131,10 +131,28 @@ export class RequestTracker {
|
|
|
131
131
|
// --- SSE client management ---
|
|
132
132
|
addClient(res) {
|
|
133
133
|
this.clients.add(res);
|
|
134
|
+
// 连接时立即推送当前活跃请求,让新客户端看到页面加载前已存在的请求
|
|
135
|
+
this.sendInitialSnapshot(res);
|
|
134
136
|
res.on("close", () => {
|
|
135
137
|
this.clients.delete(res);
|
|
136
138
|
});
|
|
137
139
|
}
|
|
140
|
+
/** 向单个客户端发送当前活跃请求快照 */
|
|
141
|
+
sendInitialSnapshot(res) {
|
|
142
|
+
const active = this.getActive().map((req) => {
|
|
143
|
+
const copy = { ...req };
|
|
144
|
+
delete copy.clientRequest;
|
|
145
|
+
return copy;
|
|
146
|
+
});
|
|
147
|
+
const msg = `event: request_update\ndata: ${JSON.stringify(active)}\n\n`;
|
|
148
|
+
try {
|
|
149
|
+
if (!res.writableEnded)
|
|
150
|
+
res.write(msg);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
this.clients.delete(res);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
138
156
|
removeClient(res) {
|
|
139
157
|
this.clients.delete(res);
|
|
140
158
|
}
|
package/dist/proxy/anthropic.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
1
2
|
import fp from "fastify-plugin";
|
|
3
|
+
import { insertRequestLog } from "../db/index.js";
|
|
2
4
|
import { createErrorFormatter } from "./proxy-core.js";
|
|
3
5
|
import { handleProxyRequest } from "./proxy-handler.js";
|
|
4
6
|
import { createOrchestrator } from "./orchestrator.js";
|
|
7
|
+
import { HTTP_BAD_GATEWAY } from "../constants.js";
|
|
5
8
|
const MESSAGES_PATH = "/v1/messages";
|
|
6
9
|
const ANTHROPIC_ERROR_TYPE = {
|
|
7
10
|
modelNotFound: "not_found_error",
|
|
@@ -11,6 +14,7 @@ const ANTHROPIC_ERROR_TYPE = {
|
|
|
11
14
|
upstreamConnectionFailed: "upstream_error",
|
|
12
15
|
concurrencyQueueFull: "api_error",
|
|
13
16
|
concurrencyTimeout: "api_error",
|
|
17
|
+
promptTooLong: "invalid_request_error",
|
|
14
18
|
};
|
|
15
19
|
const anthropicErrors = createErrorFormatter((kind, message) => ({ type: "error", error: { type: ANTHROPIC_ERROR_TYPE[kind], message } }));
|
|
16
20
|
const anthropicProxyRaw = (app, opts, done) => {
|
|
@@ -18,6 +22,15 @@ const anthropicProxyRaw = (app, opts, done) => {
|
|
|
18
22
|
const orchestrator = createOrchestrator(semaphoreManager, tracker);
|
|
19
23
|
app.post(MESSAGES_PATH, async (request, reply) => {
|
|
20
24
|
if (!orchestrator) {
|
|
25
|
+
const body = request.body;
|
|
26
|
+
insertRequestLog(db, {
|
|
27
|
+
id: randomUUID(), api_type: "anthropic", model: body?.model || null,
|
|
28
|
+
provider_id: null, status_code: HTTP_BAD_GATEWAY, latency_ms: 0, is_stream: 0,
|
|
29
|
+
error_message: "Orchestrator not available (missing semaphore or tracker)",
|
|
30
|
+
created_at: new Date().toISOString(),
|
|
31
|
+
client_request: JSON.stringify({ headers: request.headers }),
|
|
32
|
+
router_key_id: request.routerKey?.id ?? null,
|
|
33
|
+
});
|
|
21
34
|
const e = anthropicErrors.providerUnavailable();
|
|
22
35
|
return reply.code(e.statusCode).send(e.body);
|
|
23
36
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
/** synthetic tool_use 的 ID
|
|
1
|
+
/** synthetic tool_use 的 ID 前缀,用于识别 model 选择的 AskUserQuestion 响应 */
|
|
2
2
|
export declare const TOOL_USE_ID_PREFIX = "toolu_router_";
|
|
3
|
+
/** synthetic tool_use 的 ID 前缀,用于识别 provider 选择的 AskUserQuestion 响应(两步式) */
|
|
4
|
+
export declare const TOOL_USE_ID_PROVIDER_PREFIX = "toolu_router_prov_";
|
|
3
5
|
export interface DirectiveParseResult {
|
|
4
6
|
modelName: string | null;
|
|
5
7
|
command: string | null;
|
|
@@ -10,9 +12,13 @@ export declare function parseDirective(body: Record<string, unknown>): Directive
|
|
|
10
12
|
export interface ToolResultParseResult {
|
|
11
13
|
isRouterToolResult: boolean;
|
|
12
14
|
selectedModel: string | null;
|
|
15
|
+
/** true = 用户选择了 provider(两步式第一步) */
|
|
16
|
+
isProviderSelection: boolean;
|
|
17
|
+
/** 所有答案(多问题时可从中查找非"不选择"的回答) */
|
|
18
|
+
allAnswers: string[];
|
|
13
19
|
}
|
|
14
20
|
/**
|
|
15
21
|
* 检测请求中是否包含对 router synthetic AskUserQuestion 的 tool_result 回调,
|
|
16
|
-
*
|
|
22
|
+
* 如果是,从中提取用户选择的模型名或 provider 名。
|
|
17
23
|
*/
|
|
18
24
|
export declare function parseToolResult(body: Record<string, unknown>): ToolResultParseResult;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
const MODEL_MAX_LEN = 128;
|
|
2
2
|
const MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/;
|
|
3
|
-
/** synthetic tool_use 的 ID
|
|
3
|
+
/** synthetic tool_use 的 ID 前缀,用于识别 model 选择的 AskUserQuestion 响应 */
|
|
4
4
|
export const TOOL_USE_ID_PREFIX = "toolu_router_";
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
* 格式:User has answered your questions: "question"="answer". ...
|
|
8
|
-
*/
|
|
9
|
-
const RE_TOOL_RESULT_ANSWER = /="([^"]+)"\./;
|
|
5
|
+
/** synthetic tool_use 的 ID 前缀,用于识别 provider 选择的 AskUserQuestion 响应(两步式) */
|
|
6
|
+
export const TOOL_USE_ID_PROVIDER_PREFIX = "toolu_router_prov_";
|
|
10
7
|
function isValidModelName(name) {
|
|
11
8
|
return name.length <= MODEL_MAX_LEN && MODEL_RE.test(name) && !/^\d+$/.test(name);
|
|
12
9
|
}
|
|
@@ -77,12 +74,13 @@ export function parseDirective(body) {
|
|
|
77
74
|
}
|
|
78
75
|
/**
|
|
79
76
|
* 检测请求中是否包含对 router synthetic AskUserQuestion 的 tool_result 回调,
|
|
80
|
-
*
|
|
77
|
+
* 如果是,从中提取用户选择的模型名或 provider 名。
|
|
81
78
|
*/
|
|
82
79
|
export function parseToolResult(body) {
|
|
80
|
+
const empty = { isRouterToolResult: false, selectedModel: null, isProviderSelection: false, allAnswers: [] };
|
|
83
81
|
const messages = body.messages;
|
|
84
82
|
if (!messages?.length)
|
|
85
|
-
return
|
|
83
|
+
return empty;
|
|
86
84
|
let lastUserIdx = -1;
|
|
87
85
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
88
86
|
if (messages[i].role === "user") {
|
|
@@ -91,7 +89,7 @@ export function parseToolResult(body) {
|
|
|
91
89
|
}
|
|
92
90
|
}
|
|
93
91
|
if (lastUserIdx < 0)
|
|
94
|
-
return
|
|
92
|
+
return empty;
|
|
95
93
|
const lastUser = messages[lastUserIdx];
|
|
96
94
|
const blocks = Array.isArray(lastUser.content) ? lastUser.content : [lastUser.content];
|
|
97
95
|
for (const block of blocks) {
|
|
@@ -100,15 +98,44 @@ export function parseToolResult(body) {
|
|
|
100
98
|
const b = block;
|
|
101
99
|
if (b.type !== "tool_result" || typeof b.tool_use_id !== "string")
|
|
102
100
|
continue;
|
|
103
|
-
|
|
101
|
+
const isProviderSelection = b.tool_use_id.startsWith(TOOL_USE_ID_PROVIDER_PREFIX);
|
|
102
|
+
// provider 前缀也以 toolu_router_ 开头,因此先检查 provider 前缀
|
|
103
|
+
const isRouterToolResult = isProviderSelection || b.tool_use_id.startsWith(TOOL_USE_ID_PREFIX);
|
|
104
|
+
if (!isRouterToolResult)
|
|
104
105
|
continue;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
106
|
+
// 支持 string 和 content blocks 数组两种格式
|
|
107
|
+
let text = "";
|
|
108
|
+
if (typeof b.content === "string") {
|
|
109
|
+
text = b.content;
|
|
110
|
+
}
|
|
111
|
+
else if (Array.isArray(b.content)) {
|
|
112
|
+
text = b.content
|
|
113
|
+
.filter(c => c?.type === "text" && typeof c.text === "string")
|
|
114
|
+
.map(c => c.text)
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
117
|
+
const answers = [];
|
|
118
|
+
let match;
|
|
119
|
+
// 宽松匹配:提取所有 ="answer" 对(Claude Code 格式: "question"="answer". )
|
|
120
|
+
const re = /="([^"]+)"/g;
|
|
121
|
+
while ((match = re.exec(text)) !== null) {
|
|
122
|
+
answers.push(match[1]);
|
|
123
|
+
}
|
|
124
|
+
// Fallback: 尝试从 JSON {"question": "answer", ...} 提取
|
|
125
|
+
if (answers.length === 0 && text.startsWith("{")) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(text);
|
|
128
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
129
|
+
for (const v of Object.values(parsed)) {
|
|
130
|
+
if (typeof v === "string")
|
|
131
|
+
answers.push(v);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { /* not JSON */ }
|
|
109
136
|
}
|
|
110
|
-
|
|
111
|
-
return { isRouterToolResult: true, selectedModel:
|
|
137
|
+
const selectedModel = answers.length > 0 ? answers[0] : null;
|
|
138
|
+
return { isRouterToolResult: true, selectedModel, isProviderSelection, allAnswers: answers };
|
|
112
139
|
}
|
|
113
|
-
return
|
|
140
|
+
return empty;
|
|
114
141
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnhancementConfig } from "../enhancement-config.js";
|
|
3
3
|
import { getActiveProviderModels, resolveByProviderModel } from "../../db/index.js";
|
|
4
4
|
import { resolveMapping } from "../mapping-resolver.js";
|
|
5
|
-
import { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX } from "./directive-parser.js";
|
|
5
|
+
import { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX, TOOL_USE_ID_PROVIDER_PREFIX } from "./directive-parser.js";
|
|
6
6
|
import { modelState } from "../model-state.js";
|
|
7
7
|
import { cleanRouterResponses } from "./response-cleaner.js";
|
|
8
8
|
const MODEL_INFO_TAG_TYPE = "model-info";
|
|
9
|
+
const SKIP_LABEL = "不选择";
|
|
10
|
+
const TWO_STEP_THRESHOLD = 9;
|
|
11
|
+
const MODELS_PER_GROUP = 3;
|
|
9
12
|
/**
|
|
10
13
|
* 解析 "provider_name/backend_model" 格式,返回对应的 client_model。
|
|
11
14
|
* provider_name 只允许 [a-zA-Z0-9_-],/ 作为分隔符。
|
|
@@ -37,7 +40,7 @@ function buildDisplayModels(db, allowedModelsRaw) {
|
|
|
37
40
|
if (parsed.length > 0)
|
|
38
41
|
allowedSet = new Set(parsed);
|
|
39
42
|
}
|
|
40
|
-
catch { /*
|
|
43
|
+
catch { /* eslint-disable-line taste/no-silent-catch -- JSON.parse 解析失败时不做过滤,属于预期降级 */ }
|
|
41
44
|
}
|
|
42
45
|
const filtered = allowedSet
|
|
43
46
|
? providerModels.filter(m => allowedSet.has(m.backend_model))
|
|
@@ -59,45 +62,83 @@ function buildDisplayModels(db, allowedModelsRaw) {
|
|
|
59
62
|
*/
|
|
60
63
|
export function applyEnhancement(db, request, clientModel, sessionId) {
|
|
61
64
|
const nullResult = { effectiveModel: clientModel, originalModel: null, interceptResponse: null };
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
enhancement = enhancementRaw ? JSON.parse(enhancementRaw) : null;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
request.log.warn("Invalid proxy_enhancement JSON, feature disabled");
|
|
69
|
-
}
|
|
70
|
-
if (enhancement?.claude_code_enabled !== true) {
|
|
65
|
+
const enhancement = loadEnhancementConfig(db);
|
|
66
|
+
if (!enhancement.claude_code_enabled) {
|
|
71
67
|
return nullResult;
|
|
72
68
|
}
|
|
73
|
-
// 检测 AskUserQuestion 的 tool_result 回调(用户在 UI
|
|
69
|
+
// 检测 AskUserQuestion 的 tool_result 回调(用户在 UI 上选择了模型或 provider)
|
|
74
70
|
const toolResult = parseToolResult(request.body);
|
|
75
71
|
if (toolResult.isRouterToolResult) {
|
|
76
72
|
const routerKeyId = request.routerKey?.id ?? null;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
const nonSkipAnswers = toolResult.allAnswers.filter(a => a !== SKIP_LABEL);
|
|
74
|
+
// 所有回答都是"不选择" → 取消
|
|
75
|
+
if (nonSkipAnswers.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
effectiveModel: clientModel,
|
|
78
|
+
originalModel: null,
|
|
79
|
+
interceptResponse: {
|
|
80
|
+
...buildTextResponse("model-select-cancelled", "已取消选择"),
|
|
81
|
+
meta: { action: "取消模型选择" },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// 选择了多个 → 提示错误
|
|
86
|
+
if (nonSkipAnswers.length > 1) {
|
|
87
|
+
return {
|
|
88
|
+
effectiveModel: clientModel,
|
|
89
|
+
originalModel: null,
|
|
90
|
+
interceptResponse: {
|
|
91
|
+
...buildTextResponse("model-select-error", "选择错误:只能选择一个模型或提供商,请重新输入 /select-model 选择"),
|
|
92
|
+
meta: { action: "选择错误" },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const answer = nonSkipAnswers[0];
|
|
97
|
+
// 两步式:用户选择了 provider → 返回该 provider 的模型列表
|
|
98
|
+
if (toolResult.isProviderSelection) {
|
|
99
|
+
const allModels = buildDisplayModels(db, request.routerKey?.allowed_models ?? null);
|
|
100
|
+
const providerModels = getModelsForProvider(allModels, answer);
|
|
101
|
+
if (providerModels.length === 0) {
|
|
81
102
|
return {
|
|
82
|
-
effectiveModel:
|
|
103
|
+
effectiveModel: clientModel,
|
|
83
104
|
originalModel: null,
|
|
84
105
|
interceptResponse: {
|
|
85
|
-
...buildTextResponse("
|
|
86
|
-
meta: { action: "
|
|
106
|
+
...buildTextResponse("error", `未找到 provider: ${answer}`),
|
|
107
|
+
meta: { action: "模型选择失败", detail: answer },
|
|
87
108
|
},
|
|
88
109
|
};
|
|
89
110
|
}
|
|
111
|
+
const questions = buildModelQuestions(providerModels);
|
|
90
112
|
return {
|
|
91
113
|
effectiveModel: clientModel,
|
|
92
114
|
originalModel: null,
|
|
93
115
|
interceptResponse: {
|
|
94
|
-
...
|
|
95
|
-
meta: { action:
|
|
116
|
+
...buildAskUserQuestionPayload(questions, false, providerModels),
|
|
117
|
+
meta: { action: `模型列表(provider=${answer})` },
|
|
96
118
|
},
|
|
97
119
|
};
|
|
98
120
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
121
|
+
// 模型选择(直接或两步式第二步)
|
|
122
|
+
const resolvedClientModel = resolveProviderModel(db, answer);
|
|
123
|
+
if (resolvedClientModel) {
|
|
124
|
+
modelState.set(routerKeyId, answer, sessionId, clientModel, "command");
|
|
125
|
+
return {
|
|
126
|
+
effectiveModel: answer,
|
|
127
|
+
originalModel: null,
|
|
128
|
+
interceptResponse: {
|
|
129
|
+
...buildTextResponse("model-selected", `已选择模型: ${answer}`),
|
|
130
|
+
meta: { action: "模型选择", detail: answer },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
effectiveModel: clientModel,
|
|
136
|
+
originalModel: null,
|
|
137
|
+
interceptResponse: {
|
|
138
|
+
...buildTextResponse("error", `未找到模型: ${answer}`),
|
|
139
|
+
meta: { action: "模型选择失败", detail: answer },
|
|
140
|
+
},
|
|
141
|
+
};
|
|
101
142
|
}
|
|
102
143
|
// 清理历史消息中的 <router-response> 标签
|
|
103
144
|
const cleaned = cleanRouterResponses(request.body);
|
|
@@ -144,11 +185,46 @@ export function applyEnhancement(db, request, clientModel, sessionId) {
|
|
|
144
185
|
},
|
|
145
186
|
};
|
|
146
187
|
}
|
|
188
|
+
// >= TWO_STEP_THRESHOLD 且多个 provider → 两步式:先选 provider
|
|
189
|
+
if (displayModels.length >= TWO_STEP_THRESHOLD) {
|
|
190
|
+
const providers = getUniqueProviders(displayModels);
|
|
191
|
+
if (providers.length >= 2) {
|
|
192
|
+
const providerQs = buildProviderQuestions(providers);
|
|
193
|
+
return {
|
|
194
|
+
effectiveModel: clientModel,
|
|
195
|
+
originalModel: null,
|
|
196
|
+
interceptResponse: {
|
|
197
|
+
...buildAskUserQuestionPayload(providerQs, true, displayModels),
|
|
198
|
+
meta: { action: "Provider列表(AskUserQuestion)" },
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// 单 provider 且模型过多 → AskUserQuestion 显示前 6 个 + 文本列出剩余
|
|
203
|
+
const capped = displayModels.slice(0, MODELS_PER_GROUP * 2);
|
|
204
|
+
const questions = buildModelQuestions(capped);
|
|
205
|
+
const payload = buildAskUserQuestionPayload(questions, false);
|
|
206
|
+
if (displayModels.length > capped.length) {
|
|
207
|
+
const extra = displayModels.slice(capped.length).map((m, i) => `${capped.length + i + 1}. ${m}`).join("\n");
|
|
208
|
+
const textBlock = { type: "text", text: `更多模型:\n${extra}\n\n可输入 /select-model provider/model 选择` };
|
|
209
|
+
const body = payload.body;
|
|
210
|
+
body.content = [textBlock, ...body.content];
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
effectiveModel: clientModel,
|
|
214
|
+
originalModel: null,
|
|
215
|
+
interceptResponse: {
|
|
216
|
+
...payload,
|
|
217
|
+
meta: { action: "模型列表(AskUserQuestion)" },
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// < TWO_STEP_THRESHOLD → AskUserQuestion 2 组
|
|
222
|
+
const questions = buildModelQuestions(displayModels);
|
|
147
223
|
return {
|
|
148
224
|
effectiveModel: clientModel,
|
|
149
225
|
originalModel: null,
|
|
150
226
|
interceptResponse: {
|
|
151
|
-
...
|
|
227
|
+
...buildAskUserQuestionPayload(questions, false),
|
|
152
228
|
meta: { action: "模型列表(AskUserQuestion)" },
|
|
153
229
|
},
|
|
154
230
|
};
|
|
@@ -205,6 +281,27 @@ function buildTextResponse(type, inner) {
|
|
|
205
281
|
};
|
|
206
282
|
return { statusCode: 200, body };
|
|
207
283
|
}
|
|
284
|
+
/** 从 "provider/model" 列表中提取去重的 provider 名称 */
|
|
285
|
+
function getUniqueProviders(models) {
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
const result = [];
|
|
288
|
+
for (const m of models) {
|
|
289
|
+
const sep = m.indexOf("/");
|
|
290
|
+
if (sep > 0) {
|
|
291
|
+
const p = m.substring(0, sep);
|
|
292
|
+
if (!seen.has(p)) {
|
|
293
|
+
seen.add(p);
|
|
294
|
+
result.push(p);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
/** 按 provider 筛选模型列表 */
|
|
301
|
+
function getModelsForProvider(models, provider) {
|
|
302
|
+
const prefix = provider + "/";
|
|
303
|
+
return models.filter(m => m.startsWith(prefix));
|
|
304
|
+
}
|
|
208
305
|
/** 查询所有可用的 provider_model 并构造文本列表响应 */
|
|
209
306
|
function buildSelectModelResponse(db, allowedModelsRaw, selectedModel) {
|
|
210
307
|
const displayModels = buildDisplayModels(db, allowedModelsRaw);
|
|
@@ -224,17 +321,15 @@ function buildSelectModelResponse(db, allowedModelsRaw, selectedModel) {
|
|
|
224
321
|
}
|
|
225
322
|
return buildTextResponse(responseType, inner);
|
|
226
323
|
}
|
|
227
|
-
/**
|
|
324
|
+
/** 将模型列表分成最多 2 组 AskUserQuestion(每组 ≤3 个模型 + 1 个"不选择") */
|
|
228
325
|
function buildModelQuestions(models) {
|
|
229
|
-
if (models.length <=
|
|
326
|
+
if (models.length <= MODELS_PER_GROUP) {
|
|
230
327
|
const options = models.map(m => {
|
|
231
328
|
const sep = m.indexOf("/");
|
|
232
329
|
const provider = sep > 0 ? m.substring(0, sep) : "";
|
|
233
330
|
return { label: m, description: provider || "模型" };
|
|
234
331
|
});
|
|
235
|
-
|
|
236
|
-
options.push({ label: "保持当前", description: "不切换模型" });
|
|
237
|
-
}
|
|
332
|
+
options.push({ label: SKIP_LABEL, description: "不切换模型" });
|
|
238
333
|
return [{
|
|
239
334
|
question: "请选择要使用的模型",
|
|
240
335
|
header: "模型选择",
|
|
@@ -242,40 +337,75 @@ function buildModelQuestions(models) {
|
|
|
242
337
|
multiSelect: false,
|
|
243
338
|
}];
|
|
244
339
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
chunks[i % numChunks].push(models[i]);
|
|
250
|
-
}
|
|
251
|
-
return chunks.map((chunk, idx) => ({
|
|
252
|
-
question: "请选择要使用的模型",
|
|
253
|
-
header: idx === 0 ? "模型选择" : "更多模型",
|
|
254
|
-
options: chunk.map(m => {
|
|
340
|
+
const g1 = models.slice(0, MODELS_PER_GROUP);
|
|
341
|
+
const g2 = models.slice(MODELS_PER_GROUP, MODELS_PER_GROUP * 2);
|
|
342
|
+
return [g1, g2].map((group, idx) => {
|
|
343
|
+
const options = group.map(m => {
|
|
255
344
|
const sep = m.indexOf("/");
|
|
256
345
|
const provider = sep > 0 ? m.substring(0, sep) : "";
|
|
257
346
|
return { label: m, description: provider || "模型" };
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
|
|
347
|
+
});
|
|
348
|
+
options.push({ label: SKIP_LABEL, description: "不切换模型" });
|
|
349
|
+
return {
|
|
350
|
+
question: `请选择要使用的模型(第${idx + 1}组)`,
|
|
351
|
+
header: idx === 0 ? "模型选择" : "更多模型",
|
|
352
|
+
options,
|
|
353
|
+
multiSelect: false,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** 构建 provider 选择的 AskUserQuestion questions(两步式第一步,每组 ≤3 个 provider + "不选择") */
|
|
358
|
+
function buildProviderQuestions(providers) {
|
|
359
|
+
if (providers.length <= 3) {
|
|
360
|
+
const options = providers.map(p => ({ label: p, description: `${p} 的模型` }));
|
|
361
|
+
options.push({ label: SKIP_LABEL, description: "不切换模型" });
|
|
362
|
+
return [{
|
|
363
|
+
question: "请先选择模型提供商",
|
|
364
|
+
header: "Provider",
|
|
365
|
+
options,
|
|
366
|
+
multiSelect: false,
|
|
367
|
+
}];
|
|
368
|
+
}
|
|
369
|
+
const chunks = [];
|
|
370
|
+
for (let i = 0; i < providers.length && chunks.length < 4; i += 3) {
|
|
371
|
+
chunks.push(providers.slice(i, i + 3));
|
|
372
|
+
}
|
|
373
|
+
return chunks.map((chunk, idx) => {
|
|
374
|
+
const options = chunk.map(p => ({ label: p, description: `${p} 的模型` }));
|
|
375
|
+
options.push({ label: SKIP_LABEL, description: "不切换模型" });
|
|
376
|
+
return {
|
|
377
|
+
question: chunks.length === 1
|
|
378
|
+
? "请先选择模型提供商"
|
|
379
|
+
: `请选择模型提供商(第${idx + 1}组)`,
|
|
380
|
+
header: idx === 0 ? "Provider" : `Provider(${idx + 1})`,
|
|
381
|
+
options,
|
|
382
|
+
multiSelect: false,
|
|
383
|
+
};
|
|
384
|
+
});
|
|
261
385
|
}
|
|
262
|
-
/**
|
|
263
|
-
function
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
386
|
+
/** 构造「文本列表 + AskUserQuestion」组合响应 */
|
|
387
|
+
function buildAskUserQuestionPayload(questions, isProvider, allModels) {
|
|
388
|
+
const prefix = isProvider ? TOOL_USE_ID_PROVIDER_PREFIX : TOOL_USE_ID_PREFIX;
|
|
389
|
+
const toolUseId = `${prefix}${randomUUID()}`;
|
|
390
|
+
const content = [];
|
|
391
|
+
// 先输出完整模型列表文本
|
|
392
|
+
if (allModels && allModels.length > 0) {
|
|
393
|
+
const list = allModels.map((m, i) => `${i + 1}. ${m}`).join("\n");
|
|
394
|
+
content.push({ type: "text", text: `可用模型列表:\n${list}` });
|
|
395
|
+
}
|
|
396
|
+
content.push({
|
|
397
|
+
type: "tool_use",
|
|
398
|
+
id: toolUseId,
|
|
399
|
+
name: "AskUserQuestion",
|
|
400
|
+
input: { questions },
|
|
401
|
+
});
|
|
267
402
|
return {
|
|
268
403
|
statusCode: 200,
|
|
269
404
|
body: {
|
|
270
405
|
id: `msg-${randomUUID()}`,
|
|
271
406
|
type: "message",
|
|
272
407
|
role: "assistant",
|
|
273
|
-
content
|
|
274
|
-
type: "tool_use",
|
|
275
|
-
id: toolUseId,
|
|
276
|
-
name: "AskUserQuestion",
|
|
277
|
-
input: { questions },
|
|
278
|
-
}],
|
|
408
|
+
content,
|
|
279
409
|
model: "router",
|
|
280
410
|
stop_reason: "tool_use",
|
|
281
411
|
stop_sequence: null,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { applyEnhancement, buildModelInfoTag } from "./enhancement-handler.js";
|
|
2
|
-
export { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX } from "./directive-parser.js";
|
|
2
|
+
export { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX, TOOL_USE_ID_PROVIDER_PREFIX } from "./directive-parser.js";
|
|
3
3
|
export { cleanRouterResponses } from "./response-cleaner.js";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { applyEnhancement, buildModelInfoTag } from "./enhancement-handler.js";
|
|
2
|
-
export { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX } from "./directive-parser.js";
|
|
2
|
+
export { parseDirective, parseToolResult, TOOL_USE_ID_PREFIX, TOOL_USE_ID_PROVIDER_PREFIX } from "./directive-parser.js";
|
|
3
3
|
export { cleanRouterResponses } from "./response-cleaner.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export interface EnhancementConfig {
|
|
3
|
+
claude_code_enabled: boolean;
|
|
4
|
+
}
|
|
5
|
+
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
6
|
+
export declare function loadEnhancementConfig(db: Database.Database): EnhancementConfig;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getSetting } from "../db/settings.js";
|
|
2
|
+
const DEFAULT_CONFIG = {
|
|
3
|
+
claude_code_enabled: false,
|
|
4
|
+
};
|
|
5
|
+
/** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
|
|
6
|
+
export function loadEnhancementConfig(db) {
|
|
7
|
+
const raw = getSetting(db, "proxy_enhancement");
|
|
8
|
+
if (!raw)
|
|
9
|
+
return { ...DEFAULT_CONFIG };
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
return {
|
|
13
|
+
claude_code_enabled: parsed.claude_code_enabled ?? false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { ...DEFAULT_CONFIG };
|
|
18
|
+
}
|
|
19
|
+
}
|