llm-simple-router 0.10.13 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/config/model-directory.json +1 -0
  2. package/config/recommended-providers.json +6 -5
  3. package/dist/admin/groups.js +25 -0
  4. package/dist/admin/monitor.js +15 -6
  5. package/dist/admin/providers.js +22 -3
  6. package/dist/admin/recommended.js +13 -1
  7. package/dist/config/model-context.d.ts +12 -0
  8. package/dist/config/model-context.js +96 -2
  9. package/dist/config/model-directory.json +1 -0
  10. package/dist/config/recommended-providers.json +355 -0
  11. package/dist/config/recommended-retry-rules.json +12 -0
  12. package/dist/config/recommended.d.ts +2 -0
  13. package/dist/config/version.json +1 -0
  14. package/dist/core/monitor/request-tracker.d.ts +1 -1
  15. package/dist/core/monitor/request-tracker.js +2 -1
  16. package/dist/core/monitor/types.d.ts +1 -0
  17. package/dist/core/types.d.ts +1 -0
  18. package/dist/index.js +17 -1
  19. package/dist/metrics/metrics-extractor.js +3 -0
  20. package/dist/proxy/handler/create-proxy-handler.js +15 -0
  21. package/dist/proxy/handler/failover-loop.js +88 -63
  22. package/dist/proxy/pipeline-snapshot.d.ts +9 -1
  23. package/dist/proxy/proxy-logging.js +2 -2
  24. package/dist/proxy/routing/modality-redirect.d.ts +22 -0
  25. package/dist/proxy/routing/modality-redirect.js +252 -0
  26. package/dist/proxy/routing/overflow.d.ts +11 -0
  27. package/dist/proxy/routing/overflow.js +24 -0
  28. package/dist/proxy/transform/plugin-registry.js +1 -1
  29. package/dist/proxy/transform/stream-oa2ant.js +3 -0
  30. package/dist/proxy/transport/http.js +6 -0
  31. package/dist/proxy/transport/proxy-agent.js +20 -8
  32. package/dist/proxy/transport/stream.js +8 -1
  33. package/dist/proxy/transport/transport-fn.js +12 -0
  34. package/frontend-dist/assets/CardContent-yiYaxAko.js +1 -0
  35. package/frontend-dist/assets/CardTitle-CzqSlrtn.js +1 -0
  36. package/frontend-dist/assets/Checkbox-2voapLgE.js +1 -0
  37. package/frontend-dist/assets/CollapsibleContent-DHkVSWt2.js +1 -0
  38. package/frontend-dist/assets/CollapsibleTrigger-DbVCeTdD.js +1 -0
  39. package/frontend-dist/assets/Dashboard-xT1CEwOR.js +3 -0
  40. package/frontend-dist/assets/{Input-Ey_q_5_r.js → Input-DEfnoFS3.js} +1 -1
  41. package/frontend-dist/assets/Label-CjUuzGNQ.js +1 -0
  42. package/frontend-dist/assets/Login-CJDEk-tO.js +1 -0
  43. package/frontend-dist/assets/Logs-CzdPCIYV.js +1 -0
  44. package/frontend-dist/assets/MappingEntryEditor-GejG6FYv.js +1 -0
  45. package/frontend-dist/assets/ModelCard-DdQtySPM.js +1 -0
  46. package/frontend-dist/assets/ModelMappings-DffY7Izx.js +1 -0
  47. package/frontend-dist/assets/Monitor-y6d6LInm.js +1 -0
  48. package/frontend-dist/assets/Providers-Cb-CB1yf.js +1 -0
  49. package/frontend-dist/assets/ProxyEnhancement-CywRxDop.js +1 -0
  50. package/frontend-dist/assets/QuickSetup-Nj_ysAdc.js +1 -0
  51. package/frontend-dist/assets/RetryRules-DRdeZUPt.js +1 -0
  52. package/frontend-dist/assets/RouterKeys-BHOhDgXZ.js +1 -0
  53. package/frontend-dist/assets/RovingFocusItem-NxZWBEpr.js +1 -0
  54. package/frontend-dist/assets/Schedules-C4jRCbnI.js +1 -0
  55. package/frontend-dist/assets/Settings-Cn0qnqMY.js +6 -0
  56. package/frontend-dist/assets/Setup-BjN6KU0y.js +1 -0
  57. package/frontend-dist/assets/Switch-bk3eQSZ_.js +1 -0
  58. package/frontend-dist/assets/TooltipTrigger-DmYucHtv.js +1 -0
  59. package/frontend-dist/assets/TransformRulesForm-Bo-zFABv.js +1 -0
  60. package/frontend-dist/assets/UnifiedRequestDialog-5-vBmVMH.js +3 -0
  61. package/frontend-dist/assets/VisuallyHiddenInput-BflIWQCW.js +1 -0
  62. package/frontend-dist/assets/{button-C7HO6Dyb.js → button-DZwflOXO.js} +2 -2
  63. package/frontend-dist/assets/{copy-DxwFlq2A.js → copy-zQQvOqam.js} +1 -1
  64. package/frontend-dist/assets/dialog-C7v6Gaak.js +1 -0
  65. package/frontend-dist/assets/index-ClQS69Or.css +1 -0
  66. package/frontend-dist/assets/index-PMAQyWJb.js +3 -0
  67. package/frontend-dist/assets/mappings-BpkOqnsu.js +1 -0
  68. package/frontend-dist/assets/mappings-D7Qy46v_.js +1 -0
  69. package/frontend-dist/assets/{providers-Bcea72GK.js → providers-BI5dO-j0.js} +1 -1
  70. package/frontend-dist/assets/{providers-DNICB6Kg.js → providers-BzxbZ85B.js} +1 -1
  71. package/frontend-dist/assets/{trash-2-D2SrfECO.js → trash-2-CrcHK-G_.js} +1 -1
  72. package/frontend-dist/assets/{useClipboard-CttzUerj.js → useClipboard-B4K3eogm.js} +1 -1
  73. package/frontend-dist/assets/{useLogRetention-Dv0deAan.js → useLogRetention-BNbFXLBO.js} +1 -1
  74. package/frontend-dist/index.html +3 -3
  75. package/package.json +2 -2
  76. package/frontend-dist/assets/CardContent-DfVo-N85.js +0 -1
  77. package/frontend-dist/assets/CardTitle-npwJSAlz.js +0 -1
  78. package/frontend-dist/assets/Checkbox-Ddnzkh_i.js +0 -1
  79. package/frontend-dist/assets/CollapsibleContent-BTVazeoQ.js +0 -1
  80. package/frontend-dist/assets/CollapsibleTrigger-DCQeyHrt.js +0 -1
  81. package/frontend-dist/assets/Dashboard-DjnImtwH.js +0 -3
  82. package/frontend-dist/assets/Label-Dw5HcYsL.js +0 -1
  83. package/frontend-dist/assets/Login-CSrfhhm9.js +0 -1
  84. package/frontend-dist/assets/Logs-HR1DZs1M.js +0 -1
  85. package/frontend-dist/assets/MappingEntryEditor-C9pgNL0Q.js +0 -1
  86. package/frontend-dist/assets/ModelCard-IQMwlnCm.js +0 -1
  87. package/frontend-dist/assets/ModelMappings-kRx-GL_7.js +0 -1
  88. package/frontend-dist/assets/Monitor-y1ofDNK7.js +0 -1
  89. package/frontend-dist/assets/Providers-C1bP2PoM.js +0 -1
  90. package/frontend-dist/assets/ProxyEnhancement-DQx4coxn.js +0 -1
  91. package/frontend-dist/assets/QuickSetup-DHX9-CnO.js +0 -1
  92. package/frontend-dist/assets/RetryRules-zdJE0bFL.js +0 -1
  93. package/frontend-dist/assets/RouterKeys-CD0rI4kv.js +0 -1
  94. package/frontend-dist/assets/RovingFocusItem-CFmjbm49.js +0 -1
  95. package/frontend-dist/assets/Schedules-BUm3cC6w.js +0 -1
  96. package/frontend-dist/assets/Settings-D7z5IRkY.js +0 -6
  97. package/frontend-dist/assets/Setup-i9inmgjB.js +0 -1
  98. package/frontend-dist/assets/Switch-C9DeYAnK.js +0 -1
  99. package/frontend-dist/assets/TooltipTrigger-Dr6kqGSH.js +0 -1
  100. package/frontend-dist/assets/TransformRulesForm-CyXh4jHa.js +0 -1
  101. package/frontend-dist/assets/UnifiedRequestDialog-6ZRBfjko.js +0 -3
  102. package/frontend-dist/assets/VisuallyHiddenInput-CwE9jREu.js +0 -1
  103. package/frontend-dist/assets/constants-yM0YwP2s.js +0 -1
  104. package/frontend-dist/assets/dialog-BWB1aLcT.js +0 -1
  105. package/frontend-dist/assets/index-DeeDpH_W.css +0 -1
  106. package/frontend-dist/assets/index-itL9--Q_.js +0 -3
  107. package/frontend-dist/assets/mappings-6w7mc8YK.js +0 -1
  108. package/frontend-dist/assets/mappings-C1fK_e70.js +0 -1
  109. /package/frontend-dist/assets/{common-D96jEq-h.js → common-Bvxev9Ev.js} +0 -0
  110. /package/frontend-dist/assets/{common-BpwAv-lj.js → common-Cn0QcrnY.js} +0 -0
  111. /package/frontend-dist/assets/{dashboard-DjgmcUG5.js → dashboard-Cejt1wVQ.js} +0 -0
  112. /package/frontend-dist/assets/{dashboard-COCyp2p_.js → dashboard-DLTOR0fN.js} +0 -0
  113. /package/frontend-dist/assets/{login-BTNL5nN5.js → login-BkOvA7gg.js} +0 -0
  114. /package/frontend-dist/assets/{login-Sef1i0de.js → login-DWRFsEu3.js} +0 -0
  115. /package/frontend-dist/assets/{logs-CBRLywRw.js → logs-CA8USnXG.js} +0 -0
  116. /package/frontend-dist/assets/{logs-B-6cgV12.js → logs-QPt2Ybwy.js} +0 -0
  117. /package/frontend-dist/assets/{monitor-CaDMr_KG.js → monitor-CcPZdXUM.js} +0 -0
  118. /package/frontend-dist/assets/{monitor-C9j7ppMj.js → monitor-D-0KOVTC.js} +0 -0
  119. /package/frontend-dist/assets/{proxyEnhancement-DpIVSv-g.js → proxyEnhancement-B6vdsMeK.js} +0 -0
  120. /package/frontend-dist/assets/{proxyEnhancement-rSM6KhbN.js → proxyEnhancement-UuPFs4M3.js} +0 -0
  121. /package/frontend-dist/assets/{quickSetup-CCxaqY3U.js → quickSetup-CSpWmAy-.js} +0 -0
  122. /package/frontend-dist/assets/{quickSetup-DgDENHE4.js → quickSetup-D8ruRelW.js} +0 -0
  123. /package/frontend-dist/assets/{requestDetail-DZ55ph4h.js → requestDetail-8Sp9tWNb.js} +0 -0
  124. /package/frontend-dist/assets/{requestDetail-3KCtYe1N.js → requestDetail-CcHzzKYr.js} +0 -0
  125. /package/frontend-dist/assets/{retryRules-BXrRL52J.js → retryRules-C--dd-y8.js} +0 -0
  126. /package/frontend-dist/assets/{retryRules-CToGC6cR.js → retryRules-CzLnagW_.js} +0 -0
  127. /package/frontend-dist/assets/{routerKeys-DbTg4OP1.js → routerKeys-CB2l_V7w.js} +0 -0
  128. /package/frontend-dist/assets/{routerKeys-Be7OZCn0.js → routerKeys-p_ioAckE.js} +0 -0
  129. /package/frontend-dist/assets/{schedules-Bd66RL7P.js → schedules-Cz_-Wfa_.js} +0 -0
  130. /package/frontend-dist/assets/{schedules-HDwMuDgX.js → schedules-DTgk603B.js} +0 -0
  131. /package/frontend-dist/assets/{settings-DCS-RTKl.js → settings-B5Mq1HN8.js} +0 -0
  132. /package/frontend-dist/assets/{settings-C4zZB9GY.js → settings-j3dzVXzy.js} +0 -0
  133. /package/frontend-dist/assets/{setup-CrjgRrYP.js → setup-DaeEG9ll.js} +0 -0
  134. /package/frontend-dist/assets/{setup-DmgXvgkY.js → setup-Dryg-9wL.js} +0 -0
  135. /package/frontend-dist/assets/{sidebar-3c8D7l60.js → sidebar-BQWT-QZb.js} +0 -0
  136. /package/frontend-dist/assets/{sidebar-vj4kQ6t1.js → sidebar-DYwEKca3.js} +0 -0
@@ -94,11 +94,12 @@
94
94
  "group": "智谱",
95
95
  "presets": [
96
96
  {
97
- "plan": "Coding Plan",
98
- "presetName": "zhipu-coding-plan",
99
- "apiType": "anthropic",
100
- "baseUrl": "https://open.bigmodel.cn/api/anthropic",
101
- "modelsEndpoint": "/v1/models",
97
+ "plan": "Coding Plan",
98
+ "presetName": "zhipu-coding-plan",
99
+ "apiType": "openai",
100
+ "baseUrl": "https://open.bigmodel.cn",
101
+ "upstreamPath": "/api/coding/paas/v4/chat/completions",
102
+ "modelsEndpoint": "/v1/models",
102
103
  "models": [
103
104
  "glm-5.1",
104
105
  "glm-5",
@@ -1,6 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getAllMappingGroups, createMappingGroup, updateMappingGroup, deleteMappingGroup, getProviderById, getMappingGroupById, } from "../db/index.js";
3
3
  import { HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_CONFLICT, HTTP_NOT_FOUND } from "./constants.js";
4
+ import { parseModels } from "../config/model-context.js";
4
5
  import { API_CODE, apiError } from "./api-response.js";
5
6
  const CreateGroupSchema = Type.Object({
6
7
  client_model: Type.String({ minLength: 1 }),
@@ -54,6 +55,30 @@ function validateRule(db, ruleJson) {
54
55
  if (overflowErr)
55
56
  return overflowErr;
56
57
  }
58
+ // Validate multimodal_fallback if present
59
+ const fallback = r.multimodal_fallback;
60
+ if (fallback !== undefined && fallback !== null) {
61
+ const fb = fallback;
62
+ if (!fb.provider_id) {
63
+ return "multimodal_fallback: provider_id is required";
64
+ }
65
+ if (!fb.backend_model) {
66
+ return "multimodal_fallback: backend_model is required";
67
+ }
68
+ const fbProvider = getProviderById(db, fb.provider_id);
69
+ if (!fbProvider) {
70
+ return `multimodal_fallback: provider_id '${fb.provider_id}' not found`;
71
+ }
72
+ if (fbProvider.is_active !== 1) {
73
+ return `multimodal_fallback: provider '${fbProvider.name}' is not active`;
74
+ }
75
+ // 校验 backend_model 是否在 provider 的 models 列表中
76
+ const providerModels = parseModels(fbProvider.models);
77
+ const modelExists = providerModels.some(m => m.name === fb.backend_model);
78
+ if (!modelExists) {
79
+ return `multimodal_fallback: backend_model '${fb.backend_model}' not found in provider '${fbProvider.name}' models list`;
80
+ }
81
+ }
57
82
  return undefined;
58
83
  }
59
84
  export const adminGroupRoutes = (app, options, done) => {
@@ -16,16 +16,25 @@ export const adminMonitorRoutes = (app, options, done) => {
16
16
  app.get("/admin/api/monitor/stream", (request, reply) => {
17
17
  // hijack() 让 Fastify 完全放弃响应管理,避免 onSend hook 向 SSE 流注入信封 JSON
18
18
  reply.hijack();
19
- reply.raw.writeHead(HTTP_OK, {
20
- "Content-Type": "text/event-stream",
21
- "Cache-Control": "no-cache",
22
- Connection: "keep-alive",
23
- });
24
19
  const sseClient = adaptSSEClient(reply.raw);
25
20
  tracker.addClient(sseClient);
26
- request.raw.on("close", () => {
21
+ // 在 writeHead 之前注册 close 处理器,避免竞态导致 tracker 泄漏
22
+ reply.raw.on("close", () => {
27
23
  tracker.removeClient(sseClient);
28
24
  });
25
+ // 客户端在 hijack 之前已断连,无需发送响应头
26
+ if (reply.raw.destroyed)
27
+ return;
28
+ try {
29
+ reply.raw.writeHead(HTTP_OK, {
30
+ "Content-Type": "text/event-stream",
31
+ "Cache-Control": "no-cache",
32
+ Connection: "keep-alive",
33
+ });
34
+ }
35
+ catch {
36
+ request.log.debug("client disconnected before writeHead");
37
+ }
29
38
  });
30
39
  app.get("/admin/api/monitor/request/:id", async (request, reply) => {
31
40
  const { id } = request.params;
@@ -74,6 +74,8 @@ function extractModelOverrides(models) {
74
74
  const entry = { name, patches: m.patches ?? [] };
75
75
  if (m.stream_timeout_ms != null)
76
76
  entry.stream_timeout_ms = m.stream_timeout_ms;
77
+ if (m.capabilities != null && Array.isArray(m.capabilities))
78
+ entry.capabilities = m.capabilities;
77
79
  entries.push(entry);
78
80
  if (m.name != null && m.context_window != null) {
79
81
  overrides.push({ name: m.name, context_window: m.context_window });
@@ -83,6 +85,16 @@ function extractModelOverrides(models) {
83
85
  }
84
86
  const API_KEY_PREVIEW_PREFIX_LEN = 4;
85
87
  const PROVIDER_NAME_RE = /^[a-zA-Z0-9_-]+$/;
88
+ /** 校验 base_url 是否为合法的 HTTP(S) URL */
89
+ function isValidHttpUrl(str) {
90
+ try {
91
+ const url = new URL(str);
92
+ return url.protocol === "http:" || url.protocol === "https:";
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
86
98
  const CreateProviderSchema = Type.Object({
87
99
  name: Type.String({ minLength: 1 }),
88
100
  api_type: Type.Union([Type.Literal("openai"), Type.Literal("anthropic")]),
@@ -91,7 +103,7 @@ const CreateProviderSchema = Type.Object({
91
103
  api_key: Type.String({ minLength: 1 }),
92
104
  models: Type.Optional(Type.Array(Type.Union([
93
105
  Type.String(),
94
- Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })) }),
106
+ Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })), capabilities: Type.Optional(Type.Array(Type.String())) }),
95
107
  Type.Object({ id: Type.String(), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })) })
96
108
  ]))),
97
109
  is_active: Type.Optional(Type.Number()),
@@ -112,7 +124,7 @@ const UpdateProviderSchema = Type.Object({
112
124
  api_key: Type.Optional(Type.String({ minLength: 1 })),
113
125
  models: Type.Optional(Type.Array(Type.Union([
114
126
  Type.String(),
115
- Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })) }),
127
+ Type.Object({ name: Type.String(), context_window: Type.Optional(Type.Number()), patches: Type.Optional(Type.Array(Type.String())), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })), capabilities: Type.Optional(Type.Array(Type.String())) }),
116
128
  Type.Object({ id: Type.String(), stream_timeout_ms: Type.Optional(Type.Number({ minimum: 0, maximum: 86_400_000 })) })
117
129
  ]))),
118
130
  is_active: Type.Optional(Type.Number()),
@@ -165,6 +177,9 @@ export const adminProviderRoutes = (app, options, done) => {
165
177
  if (existing) {
166
178
  return reply.code(HTTP_CONFLICT).send(apiError(API_CODE.CONFLICT_NAME, `Provider 名称 '${body.name}' 已存在`));
167
179
  }
180
+ if (!isValidHttpUrl(body.base_url)) {
181
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "base_url 格式无效,必须是以 http:// 或 https:// 开头的合法 URL"));
182
+ }
168
183
  const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
169
184
  const { entries: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
170
185
  const isAdaptiveEnabled = body.adaptive_enabled ?? 0;
@@ -231,8 +246,12 @@ export const adminProviderRoutes = (app, options, done) => {
231
246
  fields.name = body.name;
232
247
  if (body.api_type !== undefined)
233
248
  fields.api_type = body.api_type;
234
- if (body.base_url !== undefined)
249
+ if (body.base_url !== undefined) {
250
+ if (!isValidHttpUrl(body.base_url)) {
251
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "base_url 格式无效,必须是以 http:// 或 https:// 开头的合法 URL"));
252
+ }
235
253
  fields.base_url = body.base_url;
254
+ }
236
255
  if (body.upstream_path !== undefined)
237
256
  fields.upstream_path = body.upstream_path || null;
238
257
  if (body.is_active !== undefined)
@@ -1,8 +1,20 @@
1
1
  import { getRecommendedProviders, getRecommendedRetryRules, reloadConfig } from "../config/recommended.js";
2
+ import { lookupCapabilities } from "../config/model-context.js";
2
3
  export const adminRecommendedRoutes = (app, options, done) => {
3
4
  const { db } = options;
4
5
  app.get("/admin/api/recommended/providers", async (_req, reply) => {
5
- return reply.send(getRecommendedProviders());
6
+ const groups = getRecommendedProviders();
7
+ // 给每个预设的模型补上 capabilities
8
+ for (const group of groups) {
9
+ for (const preset of group.presets) {
10
+ const capMap = {};
11
+ for (const m of preset.models) {
12
+ capMap[m] = lookupCapabilities(m);
13
+ }
14
+ preset.modelCapabilities = capMap;
15
+ }
16
+ }
17
+ return reply.send(groups);
6
18
  });
7
19
  app.get("/admin/api/recommended/retry-rules", async (_req, reply) => {
8
20
  const rules = getRecommendedRetryRules();
@@ -3,16 +3,28 @@ export interface ModelInfo {
3
3
  context_window: number | null;
4
4
  patches: string[];
5
5
  stream_timeout_ms?: number;
6
+ capabilities?: string[];
6
7
  }
7
8
  export interface ModelEntry {
8
9
  name: string;
9
10
  context_window?: number;
10
11
  patches?: string[];
11
12
  stream_timeout_ms?: number;
13
+ capabilities?: string[];
12
14
  }
13
15
  export declare const MODEL_CONTEXT_WINDOWS: Record<string, number>;
16
+ /** 已知支持图片输入的模型白名单。不在表中的模型默认 [\"text\"]。 */
17
+ export declare const MODEL_CAPABILITIES: Record<string, string[]>;
14
18
  export declare const DEFAULT_CONTEXT_WINDOW = 200000;
15
19
  export declare const OVERFLOW_THRESHOLD = 1000000;
20
+ /**
21
+ * 加载 config/model-directory.json(由 sync-model-directory.sh 生成)。
22
+ * 加载失败时不覆盖默认值,fallback 到硬编码白名单。
23
+ */
24
+ export declare function loadModelDirectory(configDir?: string): void;
25
+ /** 查询模型 capabilities:显式配置 > model-directory.json > 硬编码白名单 > ["text"] */
26
+ export declare function lookupCapabilities(modelName: string): string[];
27
+ /** 查询模型上下文窗口:model-directory.json > 硬编码表 > 默认值 */
16
28
  export declare function lookupContextWindow(modelName: string): number;
17
29
  /** 标准化 patch 名称:连字符 → 下划线 */
18
30
  export declare function normalizePatchName(name: string): string;
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  export const MODEL_CONTEXT_WINDOWS = {
2
4
  // DeepSeek
3
5
  "deepseek-chat": 1000000,
@@ -79,10 +81,96 @@ export const MODEL_CONTEXT_WINDOWS = {
79
81
  "moonshotai/Kimi-K2-Instruct": 128000,
80
82
  "moonshotai/Kimi-K2.5": 256000,
81
83
  };
84
+ /** 已知支持图片输入的模型白名单。不在表中的模型默认 [\"text\"]。 */
85
+ export const MODEL_CAPABILITIES = {
86
+ // ── OpenAI ── 文档确认支持 image_url
87
+ "gpt-4o": ["text", "image"],
88
+ "gpt-4o-mini": ["text", "image"],
89
+ "gpt-4-turbo": ["text", "image"],
90
+ "gpt-4.1": ["text", "image"],
91
+ "gpt-4.1-mini": ["text", "image"],
92
+ "gpt-4.1-nano": ["text", "image"],
93
+ "o1": ["text", "image"],
94
+ "o1-pro": ["text", "image"],
95
+ "o3": ["text", "image"],
96
+ "o3-mini": ["text", "image"],
97
+ "o4-mini": ["text", "image"],
98
+ // ── Anthropic ── 文档确认支持 image content block
99
+ "claude-3.5-sonnet": ["text", "image"],
100
+ "claude-3.5-haiku": ["text", "image"],
101
+ "claude-3-opus": ["text", "image"],
102
+ "claude-4-sonnet": ["text", "image"],
103
+ "claude-4-opus": ["text", "image"],
104
+ // ── DeepSeek ──
105
+ // V3/V4 不接受 OpenAI image_url 格式(API 返回 unknown variant 'image_url')
106
+ // 只有专用视觉模型 deepseek-vl2 支持
107
+ "deepseek-vl2": ["text", "image"],
108
+ // ── 智谱 ──
109
+ // GLM-5/5.1 是纯文本 LLM;GLM-5V-Turbo / GLM-4.5V 才是视觉模型
110
+ // 文档确认视觉模型支持 image_url 格式
111
+ "glm-5v-turbo": ["text", "image", "audio", "video"],
112
+ "glm-4.5v": ["text", "image"],
113
+ "glm-4v-plus": ["text", "image"],
114
+ "glm-4v-flash": ["text", "image"],
115
+ // ── 月之暗面 ── 原生多模态架构,全部支持 image_url
116
+ "moonshot-v1-128k": ["text", "image"],
117
+ "moonshot-v1-32k": ["text", "image"],
118
+ "moonshot-v1-8k": ["text", "image"],
119
+ "kimi-k2.6": ["text", "image", "video"],
120
+ "kimi-k2.5": ["text", "image", "video"],
121
+ "kimi-k2-turbo-preview": ["text", "image"],
122
+ "kimi-k2-thinking": ["text", "image"],
123
+ "kimi-for-coding": ["text", "image"],
124
+ // ── 阿里云 Qwen ── 百炼文档确认 qwen3.6-plus/qwen3.5-plus/flash 支持 image_url
125
+ "qwen-vl-max": ["text", "image"],
126
+ "qwen-vl-plus": ["text", "image"],
127
+ "qwen3.6-plus": ["text", "image", "video"],
128
+ "qwen3.5-plus": ["text", "image", "video"],
129
+ "qwen3.5-flash": ["text", "image"],
130
+ // ── 火山引擎 ── Doubao Seed 2.0 Pro 规格:Input Text, Images, Video
131
+ "doubao-seed-2-0-pro-260215": ["text", "image", "video"],
132
+ // ── 小米 MiMo ── 只有 omni 版本支持图片,pro 版本是纯文本
133
+ "mimo-v2-omni": ["text", "image", "audio", "video"],
134
+ "mimo-v2.5": ["text", "image", "audio", "video"],
135
+ };
82
136
  export const DEFAULT_CONTEXT_WINDOW = 200000;
83
137
  export const OVERFLOW_THRESHOLD = 1000000;
138
+ let directoryCapabilities = {};
139
+ let directoryContextWindows = {};
140
+ /**
141
+ * 加载 config/model-directory.json(由 sync-model-directory.sh 生成)。
142
+ * 加载失败时不覆盖默认值,fallback 到硬编码白名单。
143
+ */
144
+ export function loadModelDirectory(configDir) {
145
+ try {
146
+ const dir = configDir ?? path.resolve(process.cwd(), "config");
147
+ const filePath = path.join(dir, "model-directory.json");
148
+ const raw = fs.readFileSync(filePath, "utf-8");
149
+ const data = JSON.parse(raw);
150
+ if (data.capabilities && typeof data.capabilities === "object") {
151
+ directoryCapabilities = data.capabilities;
152
+ }
153
+ if (data.context_windows && typeof data.context_windows === "object") {
154
+ directoryContextWindows = data.context_windows;
155
+ }
156
+ // eslint-disable-next-line taste/no-silent-catch -- 加载失败不影响启动,使用硬编码白名单兆底。但记录到 stderr 供诊断
157
+ }
158
+ catch (err) {
159
+ // 加载失败不影响启动,使用硬编码白名单兆底。但记录到 stderr 供诊断
160
+ console.error('loadModelDirectory: failed to load, using hardcoded fallback', err);
161
+ }
162
+ }
163
+ /** 查询模型 capabilities:显式配置 > model-directory.json > 硬编码白名单 > ["text"] */
164
+ export function lookupCapabilities(modelName) {
165
+ return MODEL_CAPABILITIES[modelName]
166
+ ?? directoryCapabilities[modelName]
167
+ ?? ["text"];
168
+ }
169
+ /** 查询模型上下文窗口:model-directory.json > 硬编码表 > 默认值 */
84
170
  export function lookupContextWindow(modelName) {
85
- return MODEL_CONTEXT_WINDOWS[modelName] ?? DEFAULT_CONTEXT_WINDOW;
171
+ return MODEL_CONTEXT_WINDOWS[modelName]
172
+ ?? directoryContextWindows[modelName]
173
+ ?? DEFAULT_CONTEXT_WINDOW;
86
174
  }
87
175
  /** 标准化 patch 名称:连字符 → 下划线 */
88
176
  export function normalizePatchName(name) {
@@ -122,7 +210,9 @@ export function parseModels(raw) {
122
210
  return [];
123
211
  const result = parsed.map((item) => {
124
212
  if (typeof item === 'string') {
125
- return item ? { name: item, patches: [] } : null;
213
+ return item
214
+ ? { name: item, patches: [], capabilities: lookupCapabilities(item) }
215
+ : null;
126
216
  }
127
217
  const obj = item;
128
218
  if (!obj)
@@ -139,6 +229,8 @@ export function parseModels(raw) {
139
229
  };
140
230
  if (obj.stream_timeout_ms != null)
141
231
  entry.stream_timeout_ms = obj.stream_timeout_ms;
232
+ // capabilities: 显式 > model-directory > 硬编码白名单 > 默认 ["text"]
233
+ entry.capabilities = obj.capabilities ?? lookupCapabilities(modelName);
142
234
  return entry;
143
235
  }).filter((e) => e !== null);
144
236
  modelsCache.set(raw, result);
@@ -157,6 +249,8 @@ export function buildModelInfoList(modelEntries, overrides) {
157
249
  };
158
250
  if (entry.stream_timeout_ms != null)
159
251
  info.stream_timeout_ms = entry.stream_timeout_ms;
252
+ if (entry.capabilities != null)
253
+ info.capabilities = entry.capabilities;
160
254
  return info;
161
255
  });
162
256
  }