llm-simple-router 0.9.23 → 0.9.25

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 (79) hide show
  1. package/dist/admin/providers.d.ts +2 -0
  2. package/dist/admin/providers.js +42 -1
  3. package/dist/admin/proxy-enhancement.js +4 -1
  4. package/dist/admin/routes.d.ts +2 -0
  5. package/dist/admin/routes.js +1 -1
  6. package/dist/core/container.d.ts +1 -0
  7. package/dist/core/container.js +1 -0
  8. package/dist/db/log-cleaner.js +4 -1
  9. package/dist/db/migrations/041_add_provider_proxy.sql +7 -0
  10. package/dist/db/migrations/041_create_tool_error_logs.sql +28 -0
  11. package/dist/db/providers.d.ts +9 -1
  12. package/dist/db/providers.js +3 -3
  13. package/dist/db/tool-error-logs.d.ts +6 -0
  14. package/dist/db/tool-error-logs.js +8 -0
  15. package/dist/index.js +6 -1
  16. package/dist/proxy/handler/anthropic.js +1 -1
  17. package/dist/proxy/handler/openai.js +1 -1
  18. package/dist/proxy/handler/proxy-handler-utils.d.ts +24 -0
  19. package/dist/proxy/handler/proxy-handler-utils.js +81 -0
  20. package/dist/proxy/handler/proxy-handler.d.ts +2 -0
  21. package/dist/proxy/handler/proxy-handler.js +22 -1
  22. package/dist/proxy/handler/responses.js +1 -1
  23. package/dist/proxy/routing/enhancement-config.d.ts +1 -0
  24. package/dist/proxy/routing/enhancement-config.js +2 -0
  25. package/dist/proxy/tool-error-logger.d.ts +16 -0
  26. package/dist/proxy/tool-error-logger.js +39 -0
  27. package/dist/proxy/transport/http.d.ts +4 -3
  28. package/dist/proxy/transport/http.js +8 -7
  29. package/dist/proxy/transport/proxy-agent.d.ts +16 -0
  30. package/dist/proxy/transport/proxy-agent.js +54 -0
  31. package/dist/proxy/transport/stream.d.ts +2 -1
  32. package/dist/proxy/transport/stream.js +2 -2
  33. package/dist/proxy/transport/transport-fn.d.ts +2 -0
  34. package/dist/proxy/transport/transport-fn.js +3 -2
  35. package/frontend-dist/assets/{CardContent-Cn7OKkf4.js → CardContent-D2E8XPMF.js} +1 -1
  36. package/frontend-dist/assets/{CardTitle-4FM9jzOA.js → CardTitle-Bvn47Yr0.js} +1 -1
  37. package/frontend-dist/assets/{Checkbox-peXPi_YE.js → Checkbox-CHMJbyg6.js} +1 -1
  38. package/frontend-dist/assets/{CollapsibleContent-CgqAq4Jf.js → CollapsibleContent-5Mrc8gGt.js} +1 -1
  39. package/frontend-dist/assets/{CollapsibleTrigger-DNaZoRW8.js → CollapsibleTrigger-DaAYAs8_.js} +1 -1
  40. package/frontend-dist/assets/{Dashboard-Cm4JcnYb.js → Dashboard-JuNvaAgL.js} +1 -1
  41. package/frontend-dist/assets/{Input-Br6PVSFy.js → Input-D34hdiws.js} +1 -1
  42. package/frontend-dist/assets/{Label-GZl32pWd.js → Label-NPWP7UVZ.js} +1 -1
  43. package/frontend-dist/assets/{Login-DoFRYUFk.js → Login-CiMHu-aw.js} +1 -1
  44. package/frontend-dist/assets/{Logs-3m2BEWDo.js → Logs-RRwgGUbN.js} +1 -1
  45. package/frontend-dist/assets/{MappingEntryEditor-Wpkde3VI.js → MappingEntryEditor-B4fiJi8Q.js} +1 -1
  46. package/frontend-dist/assets/{ModelCard-od6KpofN.js → ModelCard-DSpT9oxm.js} +1 -1
  47. package/frontend-dist/assets/{ModelMappings-RLgKMlDj.js → ModelMappings-CsgtxPOH.js} +1 -1
  48. package/frontend-dist/assets/{Monitor-DWQbOIYI.js → Monitor-KQ4-zFJ3.js} +1 -1
  49. package/frontend-dist/assets/Providers-B8kM2PFx.js +1 -0
  50. package/frontend-dist/assets/ProxyEnhancement-Cczah5af.js +1 -0
  51. package/frontend-dist/assets/{QuickSetup-Cy7xPzpP.js → QuickSetup-BPa2psLw.js} +1 -1
  52. package/frontend-dist/assets/{RetryRules-D6zhJ-YT.js → RetryRules-B4kfx7KE.js} +1 -1
  53. package/frontend-dist/assets/{RouterKeys-V4BYi2Xr.js → RouterKeys-B6gaOE5V.js} +1 -1
  54. package/frontend-dist/assets/{RovingFocusItem-DcZ3wJIC.js → RovingFocusItem-ZGq4Eu8v.js} +1 -1
  55. package/frontend-dist/assets/{Schedules-romgDz2G.js → Schedules-tkI3OZrg.js} +1 -1
  56. package/frontend-dist/assets/{Settings-C4kcd3m9.js → Settings-DRcVz0VH.js} +1 -1
  57. package/frontend-dist/assets/{Setup-Df7MMOO9.js → Setup-CPx8uTQg.js} +1 -1
  58. package/frontend-dist/assets/{Switch-CzzBLx2L.js → Switch-BgKvsuZd.js} +1 -1
  59. package/frontend-dist/assets/{TooltipTrigger-UwQzn5Jd.js → TooltipTrigger-C1VLFDy4.js} +1 -1
  60. package/frontend-dist/assets/{TransformRulesForm-CwHjFf_8.js → TransformRulesForm-CxUVIzWH.js} +1 -1
  61. package/frontend-dist/assets/{UnifiedRequestDialog-BbafY6jV.js → UnifiedRequestDialog-D-sQqFxg.js} +1 -1
  62. package/frontend-dist/assets/{VisuallyHiddenInput-h1_JJr8A.js → VisuallyHiddenInput-WLtFW_E8.js} +1 -1
  63. package/frontend-dist/assets/{button-BwrBFTfJ.js → button-CGbcdJgN.js} +2 -2
  64. package/frontend-dist/assets/{copy-ZvTZUKRc.js → copy-CpYWP1uM.js} +1 -1
  65. package/frontend-dist/assets/{dialog-CVXqy9Fc.js → dialog-CWAFinoK.js} +1 -1
  66. package/frontend-dist/assets/{index-C8xJ1Vhe.js → index-DDiesvp7.js} +1 -1
  67. package/frontend-dist/assets/providers-BsmC9XuH.js +1 -0
  68. package/frontend-dist/assets/providers-DWTdDUnh.js +1 -0
  69. package/frontend-dist/assets/{proxyEnhancement-mmsextmb.js → proxyEnhancement-BlhJq5sA.js} +1 -1
  70. package/frontend-dist/assets/{proxyEnhancement-Rllg4r9y.js → proxyEnhancement-Cx7MC-ly.js} +1 -1
  71. package/frontend-dist/assets/{trash-2-DSx-MkZy.js → trash-2-CAPUkICH.js} +1 -1
  72. package/frontend-dist/assets/{useClipboard-CszJHvCV.js → useClipboard-CLRvABjT.js} +1 -1
  73. package/frontend-dist/assets/{useLogRetention-BZZYkmu1.js → useLogRetention-B7v1HgoB.js} +1 -1
  74. package/frontend-dist/index.html +2 -2
  75. package/package.json +5 -3
  76. package/frontend-dist/assets/Providers-OKgt7MLq.js +0 -1
  77. package/frontend-dist/assets/ProxyEnhancement-BG3lHGzz.js +0 -1
  78. package/frontend-dist/assets/providers-BS8I-elL.js +0 -1
  79. package/frontend-dist/assets/providers-Bgp5lP_R.js +0 -1
@@ -3,11 +3,13 @@ import Database from "better-sqlite3";
3
3
  import type { StateRegistry } from "../core/registry.js";
4
4
  import type { AdaptiveController } from "@llm-router/core/concurrency";
5
5
  import type { RequestTracker } from "@llm-router/core/monitor";
6
+ import type { ProxyAgentFactory } from "../proxy/transport/proxy-agent.js";
6
7
  interface ProviderRoutesOptions {
7
8
  db: Database.Database;
8
9
  stateRegistry?: StateRegistry;
9
10
  tracker?: RequestTracker;
10
11
  adaptiveController?: AdaptiveController;
12
+ proxyAgentFactory?: ProxyAgentFactory;
11
13
  }
12
14
  export declare const adminProviderRoutes: FastifyPluginCallback<ProviderRoutesOptions>;
13
15
  export {};
@@ -96,6 +96,10 @@ const CreateProviderSchema = Type.Object({
96
96
  queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
97
97
  max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
98
98
  adaptive_enabled: Type.Optional(Type.Integer({ minimum: 0, maximum: 1 })),
99
+ proxy_type: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("socks5")])),
100
+ proxy_url: Type.Optional(Type.String({ minLength: 1 })),
101
+ proxy_username: Type.Optional(Type.String()),
102
+ proxy_password: Type.Optional(Type.String()),
99
103
  });
100
104
  const UpdateProviderSchema = Type.Object({
101
105
  name: Type.Optional(Type.String({ minLength: 1 })),
@@ -113,9 +117,13 @@ const UpdateProviderSchema = Type.Object({
113
117
  queue_timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
114
118
  max_queue_size: Type.Optional(Type.Integer({ minimum: 1 })),
115
119
  adaptive_enabled: Type.Optional(Type.Integer({ minimum: 0, maximum: 1 })),
120
+ proxy_type: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("socks5")])),
121
+ proxy_url: Type.Optional(Type.String({ minLength: 1 })),
122
+ proxy_username: Type.Optional(Type.String()),
123
+ proxy_password: Type.Optional(Type.String()),
116
124
  });
117
125
  export const adminProviderRoutes = (app, options, done) => {
118
- const { db, stateRegistry, tracker, adaptiveController } = options;
126
+ const { db, stateRegistry, tracker, adaptiveController, proxyAgentFactory } = options;
119
127
  app.get("/admin/api/providers", async (_request, reply) => {
120
128
  const encryptionKey = getSetting(db, "encryption_key");
121
129
  const providers = getAllProviders(db);
@@ -134,6 +142,10 @@ export const adminProviderRoutes = (app, options, done) => {
134
142
  queue_timeout_ms: s.queue_timeout_ms,
135
143
  max_queue_size: s.max_queue_size,
136
144
  adaptive_enabled: s.adaptive_enabled,
145
+ proxy_type: s.proxy_type,
146
+ proxy_url: s.proxy_url,
147
+ proxy_username: s.proxy_username ? decrypt(s.proxy_username, encryptionKey) : null,
148
+ proxy_password: s.proxy_password ? decrypt(s.proxy_password, encryptionKey) : null,
137
149
  concurrency_status: stateRegistry?.getProviderStatus(s.id) ?? { active: 0, queued: 0 },
138
150
  created_at: s.created_at,
139
151
  updated_at: s.updated_at,
@@ -152,6 +164,11 @@ export const adminProviderRoutes = (app, options, done) => {
152
164
  const encryptedKey = encrypt(body.api_key, getSetting(db, "encryption_key"));
153
165
  const { entries: normalizedModels, overrides: contextOverrides } = extractModelOverrides((body.models ?? []));
154
166
  const isAdaptiveEnabled = body.adaptive_enabled ?? 0;
167
+ if (body.proxy_type && !body.proxy_url) {
168
+ return reply.code(HTTP_BAD_REQUEST).send(apiError(API_CODE.VALIDATION_FAILED, "proxy_url is required when proxy_type is set"));
169
+ }
170
+ const encryptedProxyUsername = body.proxy_username ? encrypt(body.proxy_username, getSetting(db, "encryption_key")) : null;
171
+ const encryptedProxyPassword = body.proxy_password ? encrypt(body.proxy_password, getSetting(db, "encryption_key")) : null;
155
172
  const id = createProvider(db, {
156
173
  name: body.name,
157
174
  api_type: body.api_type,
@@ -165,6 +182,10 @@ export const adminProviderRoutes = (app, options, done) => {
165
182
  queue_timeout_ms: body.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms,
166
183
  max_queue_size: body.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size,
167
184
  adaptive_enabled: isAdaptiveEnabled,
185
+ proxy_type: body.proxy_type ?? null,
186
+ proxy_url: body.proxy_type ? body.proxy_url : null,
187
+ proxy_username: encryptedProxyUsername,
188
+ proxy_password: encryptedProxyPassword,
168
189
  });
169
190
  if (contextOverrides.length > 0) {
170
191
  setModelInfoForProvider(db, id, contextOverrides.map(o => ({ model_name: o.name, context_window: o.context_window })));
@@ -234,7 +255,26 @@ export const adminProviderRoutes = (app, options, done) => {
234
255
  fields.api_key = encrypt(body.api_key, getSetting(db, "encryption_key"));
235
256
  fields.api_key_preview = body.api_key.length > API_KEY_PREVIEW_MIN_LENGTH ? `${body.api_key.slice(0, API_KEY_PREVIEW_PREFIX_LEN)}...${body.api_key.slice(-API_KEY_PREVIEW_PREFIX_LEN)}` : "****";
236
257
  }
258
+ // Proxy field handling
259
+ if (body.proxy_type !== undefined) {
260
+ fields.proxy_type = body.proxy_type || null;
261
+ if (!body.proxy_type) {
262
+ fields.proxy_url = null;
263
+ fields.proxy_username = null;
264
+ fields.proxy_password = null;
265
+ }
266
+ }
267
+ if (body.proxy_url !== undefined && body.proxy_type) {
268
+ fields.proxy_url = body.proxy_url;
269
+ }
270
+ if (body.proxy_username !== undefined && body.proxy_type) {
271
+ fields.proxy_username = body.proxy_username ? encrypt(body.proxy_username, getSetting(db, "encryption_key")) : null;
272
+ }
273
+ if (body.proxy_password !== undefined && body.proxy_type) {
274
+ fields.proxy_password = body.proxy_password ? encrypt(body.proxy_password, getSetting(db, "encryption_key")) : null;
275
+ }
237
276
  updateProvider(db, id, fields);
277
+ proxyAgentFactory?.invalidate(id);
238
278
  const updated = getProviderById(db, id);
239
279
  let cascade;
240
280
  if (existing.is_active === 1 && body.is_active === 0) {
@@ -327,6 +367,7 @@ export const adminProviderRoutes = (app, options, done) => {
327
367
  continue;
328
368
  }
329
369
  }
370
+ proxyAgentFactory?.invalidate(id);
330
371
  deleteProvider(db, id);
331
372
  stateRegistry?.removeProvider(id);
332
373
  adaptiveController?.remove(id);
@@ -4,12 +4,13 @@ const UpdateProxyEnhancementSchema = Type.Object({
4
4
  tool_call_loop_enabled: Type.Boolean(),
5
5
  stream_loop_enabled: Type.Boolean(),
6
6
  tool_round_limit_enabled: Type.Boolean(),
7
+ tool_error_logging_enabled: Type.Boolean(),
7
8
  });
8
9
  export const adminProxyEnhancementRoutes = (app, options, done) => {
9
10
  const { db } = options;
10
11
  app.get("/admin/api/proxy-enhancement", async (_request, reply) => {
11
12
  const raw = getSetting(db, "proxy_enhancement");
12
- const defaults = { tool_call_loop_enabled: false, stream_loop_enabled: false, tool_round_limit_enabled: true };
13
+ const defaults = { tool_call_loop_enabled: false, stream_loop_enabled: false, tool_round_limit_enabled: true, tool_error_logging_enabled: false };
13
14
  let config = defaults;
14
15
  if (raw) {
15
16
  try {
@@ -18,6 +19,7 @@ export const adminProxyEnhancementRoutes = (app, options, done) => {
18
19
  tool_call_loop_enabled: parsed.tool_call_loop_enabled ?? false,
19
20
  stream_loop_enabled: parsed.stream_loop_enabled ?? false,
20
21
  tool_round_limit_enabled: parsed.tool_round_limit_enabled ?? true,
22
+ tool_error_logging_enabled: parsed.tool_error_logging_enabled ?? false,
21
23
  };
22
24
  }
23
25
  catch { /* eslint-disable-line taste/no-silent-catch -- invalid JSON, return defaults */ }
@@ -30,6 +32,7 @@ export const adminProxyEnhancementRoutes = (app, options, done) => {
30
32
  tool_call_loop_enabled: body.tool_call_loop_enabled,
31
33
  stream_loop_enabled: body.stream_loop_enabled,
32
34
  tool_round_limit_enabled: body.tool_round_limit_enabled,
35
+ tool_error_logging_enabled: body.tool_error_logging_enabled,
33
36
  };
34
37
  setSetting(db, "proxy_enhancement", JSON.stringify(config));
35
38
  return reply.send({ success: true });
@@ -3,6 +3,7 @@ import Database from "better-sqlite3";
3
3
  import type { StateRegistry } from "../core/registry.js";
4
4
  import type { RequestTracker } from "@llm-router/core/monitor";
5
5
  import type { AdaptiveController } from "@llm-router/core/concurrency";
6
+ import type { ProxyAgentFactory } from "../proxy/transport/proxy-agent.js";
6
7
  interface AdminRoutesOptions {
7
8
  db: Database.Database;
8
9
  stateRegistry: StateRegistry;
@@ -12,6 +13,7 @@ interface AdminRoutesOptions {
12
13
  logsDir?: string;
13
14
  pluginRegistry?: import("../proxy/transform/plugin-registry.js").PluginRegistry;
14
15
  closeFn?: () => Promise<void>;
16
+ proxyAgentFactory?: ProxyAgentFactory;
15
17
  }
16
18
  export declare const adminRoutes: FastifyPluginCallback<AdminRoutesOptions>;
17
19
  export {};
@@ -23,7 +23,7 @@ export const adminRoutes = (app, options, done) => {
23
23
  app.register(adminSetupRoutes, { db: options.db });
24
24
  app.register(adminAuthPlugin, { db: options.db });
25
25
  app.register(adminLoginRoutes, { db: options.db });
26
- app.register(adminProviderRoutes, { db: options.db, stateRegistry: options.stateRegistry, tracker: options.tracker, adaptiveController: options.adaptiveController });
26
+ app.register(adminProviderRoutes, { db: options.db, stateRegistry: options.stateRegistry, tracker: options.tracker, adaptiveController: options.adaptiveController, proxyAgentFactory: options.proxyAgentFactory });
27
27
  app.register(adminMappingRoutes, { db: options.db });
28
28
  app.register(adminGroupRoutes, { db: options.db });
29
29
  app.register(adminScheduleRoutes, { db: options.db });
@@ -9,6 +9,7 @@ export declare const SERVICE_KEYS: {
9
9
  readonly adaptiveController: "adaptiveController";
10
10
  readonly pluginRegistry: "pluginRegistry";
11
11
  readonly logFileWriter: "logFileWriter";
12
+ readonly proxyAgentFactory: "proxyAgentFactory";
12
13
  };
13
14
  export type ServiceKey = (typeof SERVICE_KEYS)[keyof typeof SERVICE_KEYS];
14
15
  /**
@@ -9,6 +9,7 @@ export const SERVICE_KEYS = {
9
9
  adaptiveController: "adaptiveController",
10
10
  pluginRegistry: "pluginRegistry",
11
11
  logFileWriter: "logFileWriter",
12
+ proxyAgentFactory: "proxyAgentFactory",
12
13
  };
13
14
  /**
14
15
  * 轻量服务容器 — 懒加载单例工厂注册表。
@@ -1,5 +1,6 @@
1
1
  import { deleteLogsBefore } from "./logs.js";
2
2
  import { getLogRetentionDays } from "./settings.js";
3
+ import { deleteToolErrorLogsBefore } from "./tool-error-logs.js";
3
4
  const MS_PER_DAY = 86_400_000;
4
5
  const CLEANUP_INTERVAL_MS = 3_600_000; // 1 小时
5
6
  /** 运行一次清理,返回删除条数 */
@@ -8,7 +9,9 @@ export function runLogCleanup(db) {
8
9
  if (days <= 0)
9
10
  return 0;
10
11
  const cutoff = new Date(Date.now() - days * MS_PER_DAY).toISOString();
11
- return deleteLogsBefore(db, cutoff);
12
+ const logDeleted = deleteLogsBefore(db, cutoff);
13
+ const toolErrorDeleted = deleteToolErrorLogsBefore(db, cutoff);
14
+ return logDeleted + toolErrorDeleted;
12
15
  }
13
16
  /** 启动定时清理,返回 handle 用于停止 */
14
17
  export function scheduleLogCleanup(db, log) {
@@ -0,0 +1,7 @@
1
+ -- 041_add_provider_proxy.sql
2
+ -- Add per-provider proxy support (SOCKS5 / HTTP CONNECT)
3
+
4
+ ALTER TABLE providers ADD COLUMN proxy_type TEXT DEFAULT NULL;
5
+ ALTER TABLE providers ADD COLUMN proxy_url TEXT DEFAULT NULL;
6
+ ALTER TABLE providers ADD COLUMN proxy_username TEXT DEFAULT NULL;
7
+ ALTER TABLE providers ADD COLUMN proxy_password TEXT DEFAULT NULL;
@@ -0,0 +1,28 @@
1
+ CREATE TABLE IF NOT EXISTS tool_error_logs (
2
+ id TEXT PRIMARY KEY,
3
+ request_log_id TEXT REFERENCES request_logs(id) ON DELETE SET NULL,
4
+ provider_id TEXT NOT NULL,
5
+ backend_model TEXT NOT NULL,
6
+ client_agent_type TEXT NOT NULL DEFAULT 'unknown'
7
+ CHECK(client_agent_type IN ('claude-code', 'pi', 'unknown')),
8
+ tool_name TEXT NOT NULL,
9
+ tool_use_id TEXT,
10
+ tool_input TEXT,
11
+ error_content TEXT,
12
+ router_key_id TEXT,
13
+ session_id TEXT,
14
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_time
18
+ ON tool_error_logs(created_at);
19
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_provider
20
+ ON tool_error_logs(provider_id, created_at);
21
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_model
22
+ ON tool_error_logs(backend_model, created_at);
23
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_tool
24
+ ON tool_error_logs(tool_name);
25
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_agent
26
+ ON tool_error_logs(client_agent_type);
27
+ CREATE INDEX IF NOT EXISTS idx_tool_error_logs_session
28
+ ON tool_error_logs(session_id);
@@ -13,6 +13,10 @@ export interface Provider {
13
13
  queue_timeout_ms: number;
14
14
  max_queue_size: number;
15
15
  adaptive_enabled: number;
16
+ proxy_type: string | null;
17
+ proxy_url: string | null;
18
+ proxy_username: string | null;
19
+ proxy_password: string | null;
16
20
  created_at: string;
17
21
  updated_at: string;
18
22
  }
@@ -41,8 +45,12 @@ export declare function createProvider(db: Database.Database, provider: {
41
45
  queue_timeout_ms?: number;
42
46
  max_queue_size?: number;
43
47
  adaptive_enabled?: number;
48
+ proxy_type?: string | null;
49
+ proxy_url?: string | null;
50
+ proxy_username?: string | null;
51
+ proxy_password?: string | null;
44
52
  }): string;
45
- export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "upstream_path" | "api_key" | "api_key_preview" | "models" | "is_active" | "max_concurrency" | "queue_timeout_ms" | "max_queue_size" | "adaptive_enabled">>): void;
53
+ export declare function updateProvider(db: Database.Database, id: string, fields: Partial<Pick<Provider, "name" | "api_type" | "base_url" | "upstream_path" | "api_key" | "api_key_preview" | "models" | "is_active" | "max_concurrency" | "queue_timeout_ms" | "max_queue_size" | "adaptive_enabled" | "proxy_type" | "proxy_url" | "proxy_username" | "proxy_password">>): void;
46
54
  export declare function deleteProvider(db: Database.Database, id: string): void;
47
55
  export declare function getActiveProviderByName(db: Database.Database, name: string): {
48
56
  id: string;
@@ -37,7 +37,7 @@ export const PROVIDER_CONCURRENCY_DEFAULTS = {
37
37
  max_queue_size: 100,
38
38
  };
39
39
  const PROVIDER_FIELDS = new Set([
40
- "name", "api_type", "base_url", "upstream_path", "api_key", "api_key_preview", "models", "is_active", "max_concurrency", "queue_timeout_ms", "max_queue_size", "adaptive_enabled",
40
+ "name", "api_type", "base_url", "upstream_path", "api_key", "api_key_preview", "models", "is_active", "max_concurrency", "queue_timeout_ms", "max_queue_size", "adaptive_enabled", "proxy_type", "proxy_url", "proxy_username", "proxy_password",
41
41
  ]);
42
42
  export function getActiveProviders(db, apiType) {
43
43
  return db
@@ -53,8 +53,8 @@ export function getProviderById(db, id) {
53
53
  export function createProvider(db, provider) {
54
54
  const id = randomUUID();
55
55
  const now = new Date().toISOString();
56
- db.prepare(`INSERT INTO providers (id, name, api_type, base_url, upstream_path, api_key, api_key_preview, models, is_active, max_concurrency, queue_timeout_ms, max_queue_size, adaptive_enabled, created_at, updated_at)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.upstream_path ?? null, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency, provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms, provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size, provider.adaptive_enabled ?? 0, now, now);
56
+ db.prepare(`INSERT INTO providers (id, name, api_type, base_url, upstream_path, api_key, api_key_preview, models, is_active, max_concurrency, queue_timeout_ms, max_queue_size, adaptive_enabled, proxy_type, proxy_url, proxy_username, proxy_password, created_at, updated_at)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, provider.name, provider.api_type, provider.base_url, provider.upstream_path ?? null, provider.api_key, provider.api_key_preview ?? null, provider.models ?? "[]", provider.is_active ?? 1, provider.max_concurrency ?? PROVIDER_CONCURRENCY_DEFAULTS.max_concurrency, provider.queue_timeout_ms ?? PROVIDER_CONCURRENCY_DEFAULTS.queue_timeout_ms, provider.max_queue_size ?? PROVIDER_CONCURRENCY_DEFAULTS.max_queue_size, provider.adaptive_enabled ?? 0, provider.proxy_type ?? null, provider.proxy_url ?? null, provider.proxy_username ?? null, provider.proxy_password ?? null, now, now);
58
58
  return id;
59
59
  }
60
60
  export function updateProvider(db, id, fields) {
@@ -0,0 +1,6 @@
1
+ import Database from "better-sqlite3";
2
+ /**
3
+ * 删除 created_at 早于 beforeDate 的 tool_error_logs 记录。
4
+ * 外键 ON DELETE SET NULL 确保 request_logs 被删后 tool_error_logs 仍保留。
5
+ */
6
+ export declare function deleteToolErrorLogsBefore(db: Database.Database, beforeDate: string): number;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 删除 created_at 早于 beforeDate 的 tool_error_logs 记录。
3
+ * 外键 ON DELETE SET NULL 确保 request_logs 被删后 tool_error_logs 仍保留。
4
+ */
5
+ export function deleteToolErrorLogsBefore(db, beforeDate) {
6
+ const changes = db.prepare("DELETE FROM tool_error_logs WHERE created_at < ?").run(beforeDate).changes;
7
+ return changes;
8
+ }
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { startUpgradeChecker, stopUpgradeChecker } from "./admin/upgrade.js";
31
31
  import fastifyStatic from "@fastify/static";
32
32
  import { ServiceContainer, SERVICE_KEYS } from "./core/container.js";
33
33
  import { LogFileWriter } from "./storage/log-file-writer.js";
34
+ import { ProxyAgentFactory } from "./proxy/transport/proxy-agent.js";
34
35
  import { scheduleLogFileMaintenance } from "./storage/log-file-compressor.js";
35
36
  import { getDetailLogEnabled, getLogFileRetentionDays } from "./db/settings.js";
36
37
  import { dirname, join } from "node:path";
@@ -215,12 +216,15 @@ export async function buildApp(options) {
215
216
  const pluginsDir = path.resolve(__dirname, "../plugins/transform");
216
217
  pluginRegistry.scanPluginsDir(pluginsDir);
217
218
  container.register(SERVICE_KEYS.pluginRegistry, () => pluginRegistry);
219
+ // 注册 ProxyAgentFactory
220
+ container.register(SERVICE_KEYS.proxyAgentFactory, () => new ProxyAgentFactory());
218
221
  // 从容器解析所有服务
219
222
  const matcher = container.resolve(SERVICE_KEYS.matcher);
220
223
  const semaphoreManager = container.resolve(SERVICE_KEYS.semaphoreManager);
221
224
  const tracker = container.resolve(SERVICE_KEYS.tracker);
222
225
  const usageWindowTracker = container.resolve(SERVICE_KEYS.usageWindowTracker);
223
226
  const adaptiveController = container.resolve(SERVICE_KEYS.adaptiveController);
227
+ const proxyAgentFactory = container.resolve(SERVICE_KEYS.proxyAgentFactory);
224
228
  // Wire adaptive controller to tracker
225
229
  tracker.setAdaptiveStatusProvider(adaptiveController);
226
230
  // 从 DB 读取已有 provider 的并发配置,初始化信号量/adaptive/tracker(共享逻辑)
@@ -247,7 +251,7 @@ export async function buildApp(options) {
247
251
  // Late-bound close ref — close 函数在 adminRoutes 注册之后才定义,
248
252
  // 但 restart API 需要在运行时调用它
249
253
  const closeRef = { fn: async () => { } };
250
- app.register(adminRoutes, { db, stateRegistry, tracker, adaptiveController, logFileWriter, logsDir, closeFn: () => closeRef.fn(), pluginRegistry });
254
+ app.register(adminRoutes, { db, stateRegistry, tracker, adaptiveController, logFileWriter, logsDir, closeFn: () => closeRef.fn(), pluginRegistry, proxyAgentFactory });
251
255
  // 前端静态文件服务(生产环境)
252
256
  const frontendDist = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../frontend-dist"));
253
257
  if (existsSync(frontendDist)) {
@@ -287,6 +291,7 @@ export async function buildApp(options) {
287
291
  // 关闭所有 SSE 长连接,防止 app.close() 因 hijack 的连接无限等待
288
292
  tracker.closeAllClients();
289
293
  semaphoreManager.removeAll();
294
+ proxyAgentFactory.invalidateAll();
290
295
  const sessionTracker = container.resolve(SERVICE_KEYS.sessionTracker);
291
296
  sessionTracker.stop();
292
297
  await app.close();
@@ -35,7 +35,7 @@ const anthropicProxyRaw = (app, opts, done) => {
35
35
  const e = anthropicErrors.providerUnavailable();
36
36
  return reply.code(e.statusCode).send(e.body);
37
37
  }
38
- const deps = { db, orchestrator, container };
38
+ const deps = { db, orchestrator, container, proxyAgentFactory: container.resolve(SERVICE_KEYS.proxyAgentFactory) };
39
39
  return handleProxyRequest(request, reply, "anthropic", MESSAGES_PATH, anthropicErrors, deps);
40
40
  });
41
41
  done();
@@ -41,7 +41,7 @@ const openaiProxyRaw = (app, opts, done) => {
41
41
  });
42
42
  return sendError(reply, openaiErrors.providerUnavailable());
43
43
  }
44
- const deps = { db, orchestrator, container };
44
+ const deps = { db, orchestrator, container, proxyAgentFactory: container.resolve(SERVICE_KEYS.proxyAgentFactory) };
45
45
  return handleProxyRequest(request, reply, "openai", CHAT_COMPLETIONS_PATH, openaiErrors, deps, {
46
46
  beforeSendProxy: (body, isStream) => {
47
47
  if (isStream && !body.stream_options) {
@@ -1,6 +1,30 @@
1
1
  import type { ContentBlock } from "@llm-router/core/monitor";
2
2
  import type { ToolCallRecord } from "@llm-router/core/loop-prevention";
3
3
  import type { TransportResult } from "../types.js";
4
+ import type { RawHeaders } from "../types.js";
5
+ export type ClientAgentType = "claude-code" | "pi" | "unknown";
6
+ export interface FailedToolResult {
7
+ toolName: string;
8
+ toolUseId: string | undefined;
9
+ toolInput: string | undefined;
10
+ errorContent: string;
11
+ }
12
+ /**
13
+ * 根据请求头识别客户端类型。
14
+ * - Claude Code 独有 x-claude-code-session-id 头
15
+ * - pi 的 User-Agent 包含 "pi-coding-agent"
16
+ */
17
+ export declare function detectClientAgentType(headers: RawHeaders): ClientAgentType;
18
+ /**
19
+ * 从请求体 messages 中提取本条请求新产生的失败 tool_result 块。
20
+ *
21
+ * 只扫描最后一条 role = "user" 且有 tool_result 的消息,
22
+ * 避免重复记录前轮请求已记录的 tool 失败。
23
+ *
24
+ * 通过向前扫描 assistant 消息中的 tool_use 块
25
+ * 关联对应的 tool_name 和 tool_input。
26
+ */
27
+ export declare function extractFailedToolResults(body: Record<string, unknown>): FailedToolResult[];
4
28
  /** 从 TransportResult 中提取最终 HTTP status code */
5
29
  export declare function getTransportStatusCode(result: TransportResult): number | null;
6
30
  /** 将 tracker blocks 序列化为前端 tryDirectParse 可解析的 JSON */
@@ -1,6 +1,87 @@
1
1
  import { createHash } from "crypto";
2
2
  import { parseToolArguments } from "../transform/sanitize.js";
3
3
  const HASH_DIGEST_LENGTH = 16;
4
+ /**
5
+ * 根据请求头识别客户端类型。
6
+ * - Claude Code 独有 x-claude-code-session-id 头
7
+ * - pi 的 User-Agent 包含 "pi-coding-agent"
8
+ */
9
+ export function detectClientAgentType(headers) {
10
+ if (headers["x-claude-code-session-id"])
11
+ return "claude-code";
12
+ const ua = String(headers["user-agent"] ?? "").toLowerCase();
13
+ if (ua.includes("pi-coding-agent"))
14
+ return "pi";
15
+ return "unknown";
16
+ }
17
+ /**
18
+ * 从请求体 messages 中提取本条请求新产生的失败 tool_result 块。
19
+ *
20
+ * 只扫描最后一条 role = "user" 且有 tool_result 的消息,
21
+ * 避免重复记录前轮请求已记录的 tool 失败。
22
+ *
23
+ * 通过向前扫描 assistant 消息中的 tool_use 块
24
+ * 关联对应的 tool_name 和 tool_input。
25
+ */
26
+ export function extractFailedToolResults(body) {
27
+ const messages = body.messages;
28
+ if (!messages || messages.length === 0)
29
+ return [];
30
+ // 第一步:向后往前找最后一个包含 tool_result 的 user 消息
31
+ let lastUserIndex = -1;
32
+ const resultBlocks = [];
33
+ for (let i = messages.length - 1; i >= 0; i--) {
34
+ const msg = messages[i];
35
+ if (msg.role !== "user")
36
+ continue;
37
+ const content = msg.content;
38
+ if (!Array.isArray(content))
39
+ continue;
40
+ for (const block of content) {
41
+ if (block.type === "tool_result") {
42
+ resultBlocks.push(block);
43
+ lastUserIndex = i;
44
+ }
45
+ }
46
+ if (resultBlocks.length > 0)
47
+ break;
48
+ }
49
+ if (lastUserIndex < 0)
50
+ return [];
51
+ // 第二步:在整个 messages 中建立 tool_use_id → { name, input } 映射
52
+ const toolUseMap = new Map();
53
+ for (let i = 0; i < lastUserIndex; i++) {
54
+ const msg = messages[i];
55
+ if (msg.role !== "assistant")
56
+ continue;
57
+ const content = Array.isArray(msg.content) ? msg.content : [];
58
+ for (const block of content) {
59
+ if (block.type === "tool_use" && block.id) {
60
+ const inputText = block.input ? JSON.stringify(block.input) : "";
61
+ toolUseMap.set(block.id, { name: block.name ?? "unknown", input: inputText });
62
+ }
63
+ }
64
+ }
65
+ // 第三步:提取 is_error === true 的 tool_result
66
+ const failures = [];
67
+ for (const block of resultBlocks) {
68
+ if (block.is_error !== true)
69
+ continue;
70
+ const toolUse = block.tool_use_id && typeof block.tool_use_id === "string"
71
+ ? toolUseMap.get(block.tool_use_id)
72
+ : undefined;
73
+ const errorContent = typeof block.content === "string"
74
+ ? block.content
75
+ : JSON.stringify(block.content ?? "");
76
+ failures.push({
77
+ toolName: toolUse?.name ?? "unknown",
78
+ toolUseId: block.tool_use_id,
79
+ toolInput: toolUse?.input,
80
+ errorContent,
81
+ });
82
+ }
83
+ return failures;
84
+ }
4
85
  /** 从 TransportResult 中提取最终 HTTP status code */
5
86
  export function getTransportStatusCode(result) {
6
87
  if (result.kind === "success" || result.kind === "error" || result.kind === "stream_error")
@@ -2,10 +2,12 @@ import type { FastifyReply, FastifyRequest } from "fastify";
2
2
  import Database from "better-sqlite3";
3
3
  import type { ProxyOrchestrator } from "../orchestration/orchestrator.js";
4
4
  import type { ProxyErrorFormatter } from "../proxy-core.js";
5
+ import type { ProxyAgentFactory } from "../transport/proxy-agent.js";
5
6
  export interface RouteHandlerDeps {
6
7
  db: Database.Database;
7
8
  orchestrator: ProxyOrchestrator;
8
9
  container: ServiceContainer;
10
+ proxyAgentFactory?: ProxyAgentFactory;
9
11
  }
10
12
  import type { ServiceContainer } from "../../core/container.js";
11
13
  export declare function handleProxyRequest(request: FastifyRequest, reply: FastifyReply, apiType: "openai" | "openai-responses" | "anthropic", upstreamPath: string, errors: ProxyErrorFormatter, deps: RouteHandlerDeps, options?: {
@@ -18,7 +18,8 @@ import { applyProviderPatches } from "../patch/index.js";
18
18
  import { PipelineSnapshot } from "../pipeline-snapshot.js";
19
19
  import { applyToolRoundLimit } from "../patch/tool-round-limiter.js";
20
20
  import { loadEnhancementConfig } from "../routing/enhancement-config.js";
21
- import { getTransportStatusCode, serializeBlocksForStorage, extractLastToolUse } from "./proxy-handler-utils.js";
21
+ import { getTransportStatusCode, serializeBlocksForStorage, extractLastToolUse, extractFailedToolResults, detectClientAgentType } from "./proxy-handler-utils.js";
22
+ import { logToolErrors } from "../tool-error-logger.js";
22
23
  const HTTP_ERROR_THRESHOLD = 400;
23
24
  const MAX_LOG_FIELD_LENGTH = 80;
24
25
  const UPSTREAM_ERROR_STATUS = 502;
@@ -125,8 +126,10 @@ async function executeFailoverLoop(ctx) {
125
126
  const config = getConfig();
126
127
  const excludeTargets = [];
127
128
  let rootLogId = null;
129
+ let toolErrorsLogged = false;
128
130
  // TransformCoordinator 无状态,只需创建一次
129
131
  const coordinator = new TransformCoordinator();
132
+ const enhancementConfig = loadEnhancementConfig(deps.db);
130
133
  while (true) {
131
134
  const startTime = Date.now();
132
135
  const logId = randomUUID();
@@ -177,6 +180,23 @@ async function executeFailoverLoop(ctx) {
177
180
  if (!provider || !provider.is_active) {
178
181
  return rejectAndReply(reply, rCtx, errors.providerUnavailable(), `Provider '${resolved.provider_id}' unavailable`, resolved.provider_id);
179
182
  }
183
+ // 工具错误日志记录 — 首次迭代时执行,记录本轮请求中的 is_error tool_result
184
+ if (enhancementConfig.tool_error_logging_enabled && !toolErrorsLogged) {
185
+ toolErrorsLogged = true;
186
+ const failures = extractFailedToolResults(pipelineBody);
187
+ if (failures.length > 0) {
188
+ request.log.info({ failures: failures.length, sessionId }, "Tool error results detected");
189
+ logToolErrors(failures, {
190
+ db: deps.db,
191
+ providerId: resolved.provider_id,
192
+ backendModel: resolved.backend_model ?? effectiveModel,
193
+ clientAgentType: detectClientAgentType(cliHdrs),
194
+ requestLogId: logId,
195
+ routerKeyId,
196
+ sessionId,
197
+ });
198
+ }
199
+ }
180
200
  // --- 溢出重定向:上下文超出时切换到更大模型(必须在 transform 之前,确保使用正确的 api_type) ---
181
201
  const overflowResult = applyOverflowRedirect(resolved, deps.db, currentBody);
182
202
  if (overflowResult) {
@@ -283,6 +303,7 @@ async function executeFailoverLoop(ctx) {
283
303
  streamTimeoutMs: getModelStreamTimeout(provider, resolved.backend_model), tracker, matcher, request,
284
304
  streamLoopEnabled, formatTransform, responseTransform, injectedHeaders,
285
305
  timeoutContext: { modelId: resolved.backend_model, providerId: provider.id },
306
+ proxyAgentFactory: deps.proxyAgentFactory,
286
307
  });
287
308
  const pipelineSnapshot = iterationSnapshot.toJSON();
288
309
  try {
@@ -38,7 +38,7 @@ const responsesProxyRaw = (app, opts, done) => {
38
38
  });
39
39
  return sendError(reply, responsesErrors.providerUnavailable());
40
40
  }
41
- const deps = { db, orchestrator, container };
41
+ const deps = { db, orchestrator, container, proxyAgentFactory: container.resolve(SERVICE_KEYS.proxyAgentFactory) };
42
42
  return handleProxyRequest(request, reply, "openai-responses", RESPONSES_PATH, responsesErrors, deps);
43
43
  };
44
44
  app.post(RESPONSES_PATH, handleResponses);
@@ -3,6 +3,7 @@ export interface EnhancementConfig {
3
3
  tool_call_loop_enabled: boolean;
4
4
  stream_loop_enabled: boolean;
5
5
  tool_round_limit_enabled: boolean;
6
+ tool_error_logging_enabled: boolean;
6
7
  }
7
8
  /** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
8
9
  export declare function loadEnhancementConfig(db: Database.Database): EnhancementConfig;
@@ -3,6 +3,7 @@ const DEFAULT_CONFIG = {
3
3
  tool_call_loop_enabled: false,
4
4
  stream_loop_enabled: false,
5
5
  tool_round_limit_enabled: true,
6
+ tool_error_logging_enabled: false,
6
7
  };
7
8
  /** 集中加载 proxy_enhancement 配置,避免多处重复 getSetting + JSON.parse */
8
9
  export function loadEnhancementConfig(db) {
@@ -15,6 +16,7 @@ export function loadEnhancementConfig(db) {
15
16
  tool_call_loop_enabled: parsed.tool_call_loop_enabled ?? false,
16
17
  stream_loop_enabled: parsed.stream_loop_enabled ?? false,
17
18
  tool_round_limit_enabled: parsed.tool_round_limit_enabled ?? true,
19
+ tool_error_logging_enabled: parsed.tool_error_logging_enabled ?? false,
18
20
  };
19
21
  }
20
22
  catch {
@@ -0,0 +1,16 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { FailedToolResult, ClientAgentType } from "./handler/proxy-handler-utils.js";
3
+ export interface ToolErrorLogContext {
4
+ db: Database.Database;
5
+ providerId: string;
6
+ backendModel: string;
7
+ clientAgentType: ClientAgentType;
8
+ requestLogId: string;
9
+ routerKeyId: string | null;
10
+ sessionId: string | undefined;
11
+ }
12
+ /**
13
+ * 将失败的 tool_result 批量写入 tool_error_logs 表。
14
+ * 每条失败记录独立一行。
15
+ */
16
+ export declare function logToolErrors(failures: FailedToolResult[], ctx: ToolErrorLogContext): void;