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.
Files changed (149) hide show
  1. package/config/recommended-providers.json +234 -19
  2. package/dist/admin/api-response.d.ts +0 -1
  3. package/dist/admin/api-response.js +8 -4
  4. package/dist/admin/groups.js +35 -0
  5. package/dist/admin/monitor.js +2 -0
  6. package/dist/admin/providers.js +188 -22
  7. package/dist/admin/proxy-enhancement.js +9 -9
  8. package/dist/config/model-context.d.ts +10 -0
  9. package/dist/config/model-context.js +105 -0
  10. package/dist/db/index.d.ts +3 -1
  11. package/dist/db/index.js +2 -1
  12. package/dist/db/logs.d.ts +4 -0
  13. package/dist/db/logs.js +7 -3
  14. package/dist/db/mappings.d.ts +2 -1
  15. package/dist/db/mappings.js +2 -2
  16. package/dist/db/migrations/023_create_provider_model_info.sql +8 -0
  17. package/dist/db/migrations/024_add_mapping_groups_is_active.sql +1 -0
  18. package/dist/db/migrations/025_add_client_status_code.sql +3 -0
  19. package/dist/db/model-info.d.ts +14 -0
  20. package/dist/db/model-info.js +27 -0
  21. package/dist/db/providers.d.ts +1 -0
  22. package/dist/db/providers.js +1 -1
  23. package/dist/index.js +15 -3
  24. package/dist/middleware/auth.js +1 -1
  25. package/dist/monitor/request-tracker.d.ts +2 -0
  26. package/dist/monitor/request-tracker.js +18 -0
  27. package/dist/proxy/anthropic.js +13 -0
  28. package/dist/proxy/enhancement/directive-parser.d.ts +8 -2
  29. package/dist/proxy/enhancement/directive-parser.js +44 -17
  30. package/dist/proxy/enhancement/enhancement-handler.js +184 -54
  31. package/dist/proxy/enhancement/index.d.ts +1 -1
  32. package/dist/proxy/enhancement/index.js +1 -1
  33. package/dist/proxy/enhancement-config.d.ts +6 -0
  34. package/dist/proxy/enhancement-config.js +19 -0
  35. package/dist/proxy/openai.js +40 -3
  36. package/dist/proxy/overflow.d.ts +18 -0
  37. package/dist/proxy/overflow.js +128 -0
  38. package/dist/proxy/patch/deepseek/index.d.ts +6 -0
  39. package/dist/proxy/patch/deepseek/index.js +11 -0
  40. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.d.ts +12 -0
  41. package/dist/proxy/patch/deepseek/patch-orphan-tool-results.js +90 -0
  42. package/dist/proxy/patch/deepseek/patch-thinking-blocks.d.ts +6 -0
  43. package/dist/proxy/patch/deepseek/patch-thinking-blocks.js +24 -0
  44. package/dist/proxy/patch/index.d.ts +9 -0
  45. package/dist/proxy/patch/index.js +17 -0
  46. package/dist/proxy/proxy-core.d.ts +9 -2
  47. package/dist/proxy/proxy-core.js +24 -2
  48. package/dist/proxy/proxy-handler.js +34 -9
  49. package/dist/proxy/proxy-logging.js +23 -2
  50. package/dist/proxy/resilience.d.ts +4 -0
  51. package/dist/proxy/resilience.js +8 -1
  52. package/dist/proxy/strategy/types.d.ts +2 -0
  53. package/dist/proxy/stream-proxy.js +2 -1
  54. package/dist/proxy/transport-fn.js +3 -2
  55. package/dist/proxy/transport.js +3 -2
  56. package/dist/proxy/types.d.ts +3 -1
  57. package/dist/proxy/types.js +5 -1
  58. package/dist/upgrade/checker.js +5 -2
  59. package/dist/utils/time-range.js +28 -13
  60. package/frontend-dist/assets/CardContent-GNY_j_L3.js +1 -0
  61. package/frontend-dist/assets/CardTitle-BhXJbSoh.js +1 -0
  62. package/frontend-dist/assets/Checkbox-n_sh6Lvx.js +1 -0
  63. package/frontend-dist/assets/CollapsibleTrigger-DDCUOXDR.js +1 -0
  64. package/frontend-dist/assets/Collection-DbtqQ1jF.js +1 -0
  65. package/frontend-dist/assets/Dashboard-Dy9frcgO.js +3 -0
  66. package/frontend-dist/assets/DialogTitle-BEWUnuJQ.js +1 -0
  67. package/frontend-dist/assets/{Input-O0ebU-Va.js → Input-CmibY9Fx.js} +1 -1
  68. package/frontend-dist/assets/Label-Cs__wFH0.js +1 -0
  69. package/frontend-dist/assets/Login-BciEc1TW.js +1 -0
  70. package/frontend-dist/assets/Logs-BkqwWW0-.js +1 -0
  71. package/frontend-dist/assets/ModelMappings-DrCJ_TCf.js +1 -0
  72. package/frontend-dist/assets/Monitor-C-b4qyuI.js +1 -0
  73. package/frontend-dist/assets/PopoverTrigger-DaKOMSVs.js +1 -0
  74. package/frontend-dist/assets/PopperContent-DZ6plcjf.js +1 -0
  75. package/frontend-dist/assets/Providers-u8utX74M.js +1 -0
  76. package/frontend-dist/assets/ProxyEnhancement-8_xhndGt.js +5 -0
  77. package/frontend-dist/assets/RetryRules-D1psYDEP.js +1 -0
  78. package/frontend-dist/assets/RouterKeys-ovPFGhjy.js +1 -0
  79. package/frontend-dist/assets/RovingFocusItem-Dsv9AkP7.js +1 -0
  80. package/frontend-dist/assets/SelectValue-BoUWfZAg.js +1 -0
  81. package/frontend-dist/assets/Settings-DXF-6A8C.js +6 -0
  82. package/frontend-dist/assets/Setup-rVLqiz0d.js +1 -0
  83. package/frontend-dist/assets/Switch-po5ZVBE3.js +1 -0
  84. package/frontend-dist/assets/TableHeader-Zyvq_0p2.js +1 -0
  85. package/frontend-dist/assets/{TabsTrigger-CPCi2HIa.js → TabsTrigger-CgDhZGkT.js} +1 -1
  86. package/frontend-dist/assets/Teleport-CgTHarey.js +3 -0
  87. package/frontend-dist/assets/TooltipTrigger-C2qO21dQ.js +1 -0
  88. package/frontend-dist/assets/UnifiedRequestDialog-Dksad8eN.js +3 -0
  89. package/frontend-dist/assets/{VisuallyHidden-Cyk-jWwh.js → VisuallyHidden-fbPmoMwi.js} +1 -1
  90. package/frontend-dist/assets/VisuallyHiddenInput-7j8wkPrW.js +1 -0
  91. package/frontend-dist/assets/alert-dialog-DbT3PzoF.js +1 -0
  92. package/frontend-dist/assets/badge-BVxnlnsH.js +1 -0
  93. package/frontend-dist/assets/{button-BQ3s7yNh.js → button-BCrIpNwA.js} +2 -2
  94. package/frontend-dist/assets/chevron-down-CWBwGxSp.js +1 -0
  95. package/frontend-dist/assets/circle-question-mark-DRkkqjgG.js +1 -0
  96. package/frontend-dist/assets/dialog-BNlCZpHK.js +1 -0
  97. package/frontend-dist/assets/file-text-BavS6SrF.js +1 -0
  98. package/frontend-dist/assets/format-K3VR67cG.js +1 -0
  99. package/frontend-dist/assets/index-BP4imfye.css +1 -0
  100. package/frontend-dist/assets/index-DrBJPq6d.js +1 -0
  101. package/frontend-dist/assets/lib-CGpNhf06.js +1 -0
  102. package/frontend-dist/assets/loader-circle-Cpd89XQ7.js +1 -0
  103. package/frontend-dist/assets/ohash.D__AXeF1-DkJnWU8a.js +1 -0
  104. package/frontend-dist/assets/{useClipboard-Cnnz6AAN.js → useClipboard-Bq8yZunx.js} +1 -1
  105. package/frontend-dist/assets/useLogRetention-BWPm3G_A.js +1 -0
  106. package/frontend-dist/assets/useNonce-D5lpSPNk.js +1 -0
  107. package/frontend-dist/assets/x-BFIp7DLt.js +1 -0
  108. package/frontend-dist/index.html +20 -17
  109. package/package.json +2 -1
  110. package/frontend-dist/assets/CardContent-WrBnGhTg.js +0 -1
  111. package/frontend-dist/assets/CardTitle-BcDYk7cq.js +0 -1
  112. package/frontend-dist/assets/Checkbox-MZf0YsDG.js +0 -1
  113. package/frontend-dist/assets/CollapsibleTrigger-CrOH9HlW.js +0 -1
  114. package/frontend-dist/assets/Collection-DcTx_Y54.js +0 -1
  115. package/frontend-dist/assets/Dashboard-D0oDrSLr.js +0 -3
  116. package/frontend-dist/assets/DialogTitle-Cl5Cd7QH.js +0 -1
  117. package/frontend-dist/assets/Label-C_S0y7Um.js +0 -1
  118. package/frontend-dist/assets/Login-DGY7uF8P.js +0 -1
  119. package/frontend-dist/assets/Logs-ls8pv89b.js +0 -1
  120. package/frontend-dist/assets/ModelMappings-DGlf0S4s.js +0 -1
  121. package/frontend-dist/assets/Monitor-BSI87grz.js +0 -1
  122. package/frontend-dist/assets/PopperContent-C6Q7hDmf.js +0 -1
  123. package/frontend-dist/assets/Providers-ZkRpj8_m.js +0 -1
  124. package/frontend-dist/assets/ProxyEnhancement-DFPI1W6Z.js +0 -5
  125. package/frontend-dist/assets/RetryRules-DtM31qsl.js +0 -1
  126. package/frontend-dist/assets/RouterKeys-D63tRFKm.js +0 -1
  127. package/frontend-dist/assets/RovingFocusItem-BJoylAKU.js +0 -1
  128. package/frontend-dist/assets/SelectValue-CLp5z6_I.js +0 -1
  129. package/frontend-dist/assets/Settings-DSgRKbTQ.js +0 -6
  130. package/frontend-dist/assets/Setup-BDmj6CRk.js +0 -1
  131. package/frontend-dist/assets/Switch-Wz-t_zkv.js +0 -1
  132. package/frontend-dist/assets/TableHeader-DGtcqGkw.js +0 -1
  133. package/frontend-dist/assets/Teleport-DdjYHlNK.js +0 -3
  134. package/frontend-dist/assets/TooltipTrigger-H_QoPY1n.js +0 -1
  135. package/frontend-dist/assets/UnifiedRequestDialog-BAAfMJJl.js +0 -3
  136. package/frontend-dist/assets/VisuallyHiddenInput-CYjNe_H8.js +0 -1
  137. package/frontend-dist/assets/alert-dialog-Bi3dliLl.js +0 -1
  138. package/frontend-dist/assets/badge-Kkta3e9W.js +0 -1
  139. package/frontend-dist/assets/createLucideIcon-D1tkPDOQ.js +0 -1
  140. package/frontend-dist/assets/dialog-DoIATUYw.js +0 -1
  141. package/frontend-dist/assets/file-text-Dt6QP1bZ.js +0 -1
  142. package/frontend-dist/assets/format-DOVIVsQC.js +0 -1
  143. package/frontend-dist/assets/index-BY0E7CHR.js +0 -1
  144. package/frontend-dist/assets/index-Bnrh1mFY.css +0 -1
  145. package/frontend-dist/assets/lib-CxwxnlwW.js +0 -1
  146. package/frontend-dist/assets/ohash.D__AXeF1-b0PiKZB_.js +0 -1
  147. package/frontend-dist/assets/useLogRetention-DYP5LOAc.js +0 -1
  148. package/frontend-dist/assets/useNonce-DKbOCfgM.js +0 -1
  149. 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: 0,
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
  }
@@ -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
  }
@@ -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 前缀,用于识别我们的 AskUserQuestion 响应 */
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 前缀,用于识别我们的 AskUserQuestion 响应 */
3
+ /** synthetic tool_use 的 ID 前缀,用于识别 model 选择的 AskUserQuestion 响应 */
4
4
  export const TOOL_USE_ID_PREFIX = "toolu_router_";
5
- /**
6
- * Claude Code AskUserQuestion 的 tool_result content 文本中提取用户选择。
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 { isRouterToolResult: false, selectedModel: null };
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 { isRouterToolResult: false, selectedModel: null };
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
- if (!b.tool_use_id.startsWith(TOOL_USE_ID_PREFIX))
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
- const text = typeof b.content === "string" ? b.content : "";
106
- const match = RE_TOOL_RESULT_ANSWER.exec(text);
107
- if (match?.[1]) {
108
- return { isRouterToolResult: true, selectedModel: match[1] };
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
- // tool_result 匹配前缀但无法提取模型名
111
- return { isRouterToolResult: true, selectedModel: null };
137
+ const selectedModel = answers.length > 0 ? answers[0] : null;
138
+ return { isRouterToolResult: true, selectedModel, isProviderSelection, allAnswers: answers };
112
139
  }
113
- return { isRouterToolResult: false, selectedModel: null };
140
+ return empty;
114
141
  }
@@ -1,11 +1,14 @@
1
1
  import { randomUUID } from "crypto";
2
- import { getSetting } from "../../db/settings.js";
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 enhancementRaw = getSetting(db, "proxy_enhancement");
63
- let enhancement = null;
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
- if (toolResult.selectedModel) {
78
- const resolvedClientModel = resolveProviderModel(db, toolResult.selectedModel);
79
- if (resolvedClientModel) {
80
- modelState.set(routerKeyId, toolResult.selectedModel, sessionId, clientModel, "command");
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: toolResult.selectedModel,
103
+ effectiveModel: clientModel,
83
104
  originalModel: null,
84
105
  interceptResponse: {
85
- ...buildTextResponse("model-selected", `已选择模型: ${toolResult.selectedModel}`),
86
- meta: { action: "模型选择", detail: toolResult.selectedModel },
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
- ...buildTextResponse("error", `未找到模型: ${toolResult.selectedModel}`),
95
- meta: { action: "模型选择失败", detail: toolResult.selectedModel },
116
+ ...buildAskUserQuestionPayload(questions, false, providerModels),
117
+ meta: { action: `模型列表(provider=${answer})` },
96
118
  },
97
119
  };
98
120
  }
99
- // tool_result 匹配前缀但无法提取模型名,降级处理
100
- return nullResult;
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
- ...buildAskUserQuestionResponse(displayModels),
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
- /** 将模型列表分块为 AskUserQuestion questions(每组 2-4 个选项,最多 4 组) */
324
+ /** 将模型列表分成最多 2 AskUserQuestion(每组 ≤3 个模型 + 1 个"不选择") */
228
325
  function buildModelQuestions(models) {
229
- if (models.length <= 4) {
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
- if (options.length === 1) {
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
- // 均匀分配到最多 4 question,每个 2-4 个选项
246
- const numChunks = Math.min(4, Math.ceil(models.length / 4));
247
- const chunks = Array.from({ length: numChunks }, () => []);
248
- for (let i = 0; i < models.length; i++) {
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
- multiSelect: false,
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
- /** 构造 AskUserQuestion synthetic tool_use 响应 */
263
- function buildAskUserQuestionResponse(displayModels) {
264
- const capped = displayModels.slice(0, 16);
265
- const questions = buildModelQuestions(capped);
266
- const toolUseId = `${TOOL_USE_ID_PREFIX}${randomUUID()}`;
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
+ }