llm-simple-router 0.10.10 → 0.10.11

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 (78) hide show
  1. package/dist/db/index.d.ts +1 -1
  2. package/dist/db/index.js +1 -1
  3. package/dist/db/migrations/046_fix_is_complete_for_successful_requests.sql +10 -0
  4. package/dist/db/stats.d.ts +2 -0
  5. package/dist/db/stats.js +16 -0
  6. package/dist/metrics/sse-metrics-transform.d.ts +2 -0
  7. package/dist/metrics/sse-metrics-transform.js +8 -0
  8. package/dist/proxy/format/registry.d.ts +2 -2
  9. package/dist/proxy/format/registry.js +9 -10
  10. package/dist/proxy/format/types.d.ts +2 -2
  11. package/dist/proxy/format/types.js +2 -2
  12. package/dist/proxy/handler/failover-loop.js +5 -6
  13. package/dist/proxy/routing/usage-window-tracker.d.ts +3 -5
  14. package/dist/proxy/routing/usage-window-tracker.js +28 -43
  15. package/dist/proxy/transform/message-mapper.js +22 -2
  16. package/dist/proxy/transform/request-bridge-responses.js +178 -12
  17. package/dist/proxy/transform/request-transform-responses.js +96 -17
  18. package/dist/proxy/transform/request-transform.js +9 -1
  19. package/dist/proxy/transform/response-bridge-responses.d.ts +2 -2
  20. package/dist/proxy/transform/response-bridge-responses.js +8 -8
  21. package/dist/proxy/transform/response-transform-responses.d.ts +3 -2
  22. package/dist/proxy/transform/response-transform-responses.js +51 -10
  23. package/dist/proxy/transform/response-transform.d.ts +4 -4
  24. package/dist/proxy/transform/response-transform.js +32 -23
  25. package/dist/proxy/transform/stream-ant2oa.js +10 -2
  26. package/dist/proxy/transform/stream-ant2resp.d.ts +6 -0
  27. package/dist/proxy/transform/stream-ant2resp.js +43 -13
  28. package/dist/proxy/transform/stream-bridge-chat2resp.d.ts +3 -0
  29. package/dist/proxy/transform/stream-bridge-chat2resp.js +20 -5
  30. package/dist/proxy/transform/stream-bridge-resp2chat.d.ts +1 -0
  31. package/dist/proxy/transform/stream-bridge-resp2chat.js +24 -0
  32. package/dist/proxy/transform/stream-oa2ant.d.ts +1 -0
  33. package/dist/proxy/transform/stream-oa2ant.js +11 -3
  34. package/dist/proxy/transform/stream-resp2ant.d.ts +1 -0
  35. package/dist/proxy/transform/stream-resp2ant.js +10 -3
  36. package/dist/proxy/transform/thinking-mapper.js +11 -1
  37. package/dist/proxy/transform/tool-mapper.js +10 -7
  38. package/dist/proxy/transform/types.d.ts +11 -0
  39. package/dist/proxy/transform/usage-mapper.js +18 -10
  40. package/dist/proxy/transport/stream.js +3 -0
  41. package/dist/utils/time-range.js +18 -23
  42. package/frontend-dist/assets/{CardContent-qcSyH2wC.js → CardContent-BinBedFB.js} +1 -1
  43. package/frontend-dist/assets/{CardTitle-BiP1PaEJ.js → CardTitle-DYzajCM3.js} +1 -1
  44. package/frontend-dist/assets/{Checkbox-DffzDfGN.js → Checkbox-BKbm21iJ.js} +1 -1
  45. package/frontend-dist/assets/{CollapsibleContent-BLcA_5sz.js → CollapsibleContent-Dec5HJHa.js} +1 -1
  46. package/frontend-dist/assets/{CollapsibleTrigger-CFNX8ZWv.js → CollapsibleTrigger-DMwCWydb.js} +1 -1
  47. package/frontend-dist/assets/{Dashboard-DuRjJaT-.js → Dashboard-Cl-WroBl.js} +1 -1
  48. package/frontend-dist/assets/{Input-D_nV5NLw.js → Input-BM3GbnIl.js} +1 -1
  49. package/frontend-dist/assets/{Label-DWvRM0Of.js → Label-birmlOXE.js} +1 -1
  50. package/frontend-dist/assets/{Login-CgiCloZ_.js → Login-C7taA6PX.js} +1 -1
  51. package/frontend-dist/assets/{Logs-XcxKaNRX.js → Logs-CK-PZhH3.js} +1 -1
  52. package/frontend-dist/assets/{MappingEntryEditor-DIoQxGfs.js → MappingEntryEditor-pNMmCPFo.js} +1 -1
  53. package/frontend-dist/assets/{ModelCard-Cm7JtPFg.js → ModelCard-Bq5fCmh_.js} +1 -1
  54. package/frontend-dist/assets/{ModelMappings-CvNdZ4UC.js → ModelMappings-8Udu3uKC.js} +1 -1
  55. package/frontend-dist/assets/{Monitor-_pspFE5H.js → Monitor-D8jwoxpP.js} +1 -1
  56. package/frontend-dist/assets/{Providers-B__y99yf.js → Providers-B-7RP1SK.js} +1 -1
  57. package/frontend-dist/assets/{ProxyEnhancement-Bsp6wbOU.js → ProxyEnhancement-CePqXqlz.js} +1 -1
  58. package/frontend-dist/assets/{QuickSetup-CLirza5Z.js → QuickSetup-C9s5YJIx.js} +1 -1
  59. package/frontend-dist/assets/{RetryRules-D7TEvRsa.js → RetryRules-T5erHKxg.js} +1 -1
  60. package/frontend-dist/assets/{RouterKeys-xsdh_HcW.js → RouterKeys-CXFTloiI.js} +1 -1
  61. package/frontend-dist/assets/{RovingFocusItem-D2UUj3n_.js → RovingFocusItem-4aJoL5yC.js} +1 -1
  62. package/frontend-dist/assets/{Schedules-D7GDAZXY.js → Schedules-XHeewD4H.js} +1 -1
  63. package/frontend-dist/assets/{Settings-BI_ABQ8W.js → Settings-DAM3J0JW.js} +1 -1
  64. package/frontend-dist/assets/{Setup-C8a8Q9z2.js → Setup-Cb9g-O9o.js} +1 -1
  65. package/frontend-dist/assets/{Switch-DhyEtE7A.js → Switch-C7cnRCS5.js} +1 -1
  66. package/frontend-dist/assets/{TooltipTrigger-BVNmzsHP.js → TooltipTrigger-C1BCMETT.js} +1 -1
  67. package/frontend-dist/assets/{TransformRulesForm--5C_xErR.js → TransformRulesForm-Cv_Ih3In.js} +1 -1
  68. package/frontend-dist/assets/{UnifiedRequestDialog-O6Ld5DZI.js → UnifiedRequestDialog-CBIzu-3j.js} +2 -2
  69. package/frontend-dist/assets/{VisuallyHiddenInput-CPQ662FS.js → VisuallyHiddenInput-C9vzGFfc.js} +1 -1
  70. package/frontend-dist/assets/{button-BWSfarSC.js → button-DTwl8zzX.js} +2 -2
  71. package/frontend-dist/assets/{copy-DOxEdrz9.js → copy-Mm5hFXXX.js} +1 -1
  72. package/frontend-dist/assets/{dialog--eSL5k5e.js → dialog-B8BVRE0T.js} +1 -1
  73. package/frontend-dist/assets/{index-B9huoJLE.js → index-DjBiyR45.js} +2 -2
  74. package/frontend-dist/assets/{trash-2-CNeuAYsm.js → trash-2-Blf2lMVT.js} +1 -1
  75. package/frontend-dist/assets/{useClipboard-CnSGwYPF.js → useClipboard-D4oejP66.js} +1 -1
  76. package/frontend-dist/assets/{useLogRetention-Dul_BUBe.js → useLogRetention-aAVdCW7-.js} +1 -1
  77. package/frontend-dist/index.html +2 -2
  78. package/package.json +1 -1
@@ -12,7 +12,7 @@ export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey
12
12
  export type { RouterKey } from "./router-keys.js";
13
13
  export { getMetricsSummary, getMetricsTimeseries, insertMetrics, getClientTypeBreakdown } from "./metrics.js";
14
14
  export type { MetricsSummaryRow, MetricsTimeseriesRow, MetricsPeriod, MetricsMetric, MetricsRow, MetricsInsert, ClientTypeBreakdown } from "./metrics.js";
15
- export { getStats } from "./stats.js";
15
+ export { getStats, getLatestMetricTime } from "./stats.js";
16
16
  export type { Stats } from "./stats.js";
17
17
  export { getSetting, setSetting, isInitialized } from "./settings.js";
18
18
  export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
package/dist/db/index.js CHANGED
@@ -145,7 +145,7 @@ export { getActiveRetryRules, getAllRetryRules, getRetryRuleById, createRetryRul
145
145
  export { insertRequestLog, getRequestLogs, getRequestLogById, deleteLogsBefore, getRequestLogChildren, getRequestLogsGrouped, updateLogStreamContent, updateLogClientStatus, estimateLogTableSize, deleteOldestLogs, getLogCount, updateLogPipelineSnapshot, } from "./logs.js";
146
146
  export { getRouterKeyByHash, getAllRouterKeys, getRouterKeyById, createRouterKey, updateRouterKey, deleteRouterKey, getAvailableModels, } from "./router-keys.js";
147
147
  export { getMetricsSummary, getMetricsTimeseries, insertMetrics, getClientTypeBreakdown } from "./metrics.js";
148
- export { getStats } from "./stats.js";
148
+ export { getStats, getLatestMetricTime } from "./stats.js";
149
149
  export { getSetting, setSetting, isInitialized } from "./settings.js";
150
150
  export { getDbMaxSizeMb, setDbMaxSizeMb, getLogTableMaxSizeMb, setLogTableMaxSizeMb, } from "./settings.js";
151
151
  export { insertWindow, getLatestWindow, getWindowsInRange, getWindowUsage, } from "./usage-windows.js";
@@ -0,0 +1,10 @@
1
+ -- Fix historical request_metrics where is_complete=0 despite successful responses.
2
+ -- Root cause: StreamProxy.onEnd() collected metrics before SSE parser flush,
3
+ -- causing is_complete to always be 0 for providers using OpenAI SSE format.
4
+ -- Only fix records with clear success signals (status 200 + output tokens + duration).
5
+ UPDATE request_metrics
6
+ SET is_complete = 1
7
+ WHERE is_complete = 0
8
+ AND status_code = 200
9
+ AND output_tokens > 0
10
+ AND total_duration_ms > 0;
@@ -1,4 +1,6 @@
1
1
  import Database from "better-sqlite3";
2
+ /** 获取指定条件下的最近一条 metric 的 created_at(用于窗口补齐定位,不限制 is_complete) */
3
+ export declare function getLatestMetricTime(db: Database.Database, providerId?: string, routerKeyId?: string): string | null;
2
4
  export interface Stats {
3
5
  totalRequests: number;
4
6
  successRate: number;
package/dist/db/stats.js CHANGED
@@ -1,3 +1,19 @@
1
+ /** 获取指定条件下的最近一条 metric 的 created_at(用于窗口补齐定位,不限制 is_complete) */
2
+ export function getLatestMetricTime(db, providerId, routerKeyId) {
3
+ const conditions = [];
4
+ const params = [];
5
+ if (providerId) {
6
+ conditions.push("rm.provider_id = ?");
7
+ params.push(providerId);
8
+ }
9
+ if (routerKeyId) {
10
+ conditions.push("rm.router_key_id = ?");
11
+ params.push(routerKeyId);
12
+ }
13
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
14
+ const row = db.prepare(`SELECT rm.created_at FROM request_metrics rm ${where} ORDER BY rm.created_at DESC LIMIT 1`).get(...params);
15
+ return row?.created_at ?? null;
16
+ }
1
17
  export function getStats(db, startTime, endTime, routerKeyId, providerId, backendModel) {
2
18
  const conditions = [
3
19
  "rm.is_complete = 1",
@@ -30,6 +30,8 @@ export declare class SSEMetricsTransform extends Transform {
30
30
  constructor(apiType: "openai" | "openai-responses" | "anthropic", requestStartTime: number, options?: MetricsTransformOptions);
31
31
  _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void;
32
32
  _flush(callback: TransformCallback): void;
33
+ /** Flush SSE parser 缓冲区并处理残余事件,确保 extractor 状态完整 */
34
+ flushParser(): void;
33
35
  getExtractor(): MetricsExtractor;
34
36
  /** 从 SSE 事件中提取内容文本,触发 onContentDelta 回调 */
35
37
  private emitContentDelta;
@@ -54,6 +54,14 @@ export class SSEMetricsTransform extends Transform {
54
54
  }
55
55
  callback();
56
56
  }
57
+ /** Flush SSE parser 缓冲区并处理残余事件,确保 extractor 状态完整 */
58
+ flushParser() {
59
+ const events = this.parser.flush();
60
+ for (const event of events) {
61
+ this.extractor.processEvent(event);
62
+ this.emitContentDelta(event);
63
+ }
64
+ }
57
65
  getExtractor() {
58
66
  return this.extractor;
59
67
  }
@@ -11,7 +11,7 @@ export declare class FormatRegistry {
11
11
  body: Record<string, unknown>;
12
12
  upstreamPath: string;
13
13
  };
14
- transformResponse(bodyStr: string, source: string, target: string): string;
15
- transformError(bodyStr: string, source: string, target: string): string;
14
+ transformResponse(body: Record<string, unknown>, source: string, target: string): Record<string, unknown>;
15
+ transformError(body: Record<string, unknown>, source: string, target: string): string;
16
16
  createStreamTransform(source: string, target: string, model: string): Transform | undefined;
17
17
  }
@@ -21,26 +21,25 @@ export class FormatRegistry {
21
21
  return { body, upstreamPath };
22
22
  return { body: converter.transformRequest(body, model), upstreamPath };
23
23
  }
24
- transformResponse(bodyStr, source, target) {
24
+ transformResponse(body, source, target) {
25
25
  const converter = this.converters.get(`${source}→${target}`);
26
26
  if (!converter)
27
- return bodyStr;
28
- return converter.transformResponse(bodyStr);
27
+ return body;
28
+ return converter.transformResponse(body);
29
29
  }
30
- transformError(bodyStr, source, target) {
30
+ transformError(body, source, target) {
31
31
  if (source === target)
32
- return bodyStr;
32
+ return JSON.stringify(body);
33
33
  try {
34
- const parsed = JSON.parse(bodyStr);
35
- const message = parsed.error?.message ?? parsed.message ?? JSON.stringify(parsed);
36
- const code = parsed.error?.code ?? parsed.code;
34
+ const message = body.error?.message ?? body.message ?? JSON.stringify(body);
35
+ const code = body.error?.code ?? body.code;
37
36
  const targetAdapter = this.adapters.get(target);
38
37
  if (!targetAdapter)
39
- return bodyStr;
38
+ return JSON.stringify(body);
40
39
  return JSON.stringify(targetAdapter.formatError(message, code));
41
40
  }
42
41
  catch {
43
- return bodyStr;
42
+ return JSON.stringify(body);
44
43
  }
45
44
  }
46
45
  createStreamTransform(source, target, model) {
@@ -14,7 +14,7 @@ export interface FormatConverter {
14
14
  readonly sourceType: string;
15
15
  readonly targetType: string;
16
16
  transformRequest(body: Record<string, unknown>, model: string): Record<string, unknown>;
17
- transformResponse(bodyStr: string): string;
17
+ transformResponse(body: Record<string, unknown>): Record<string, unknown>;
18
18
  createStreamTransform(model: string): Transform;
19
19
  }
20
20
  /** Factory: eliminates repetitive object literal structure across 6 converters. */
@@ -22,6 +22,6 @@ export declare function createConverter(deps: {
22
22
  sourceType: string;
23
23
  targetType: string;
24
24
  requestTransform: (body: Record<string, unknown>) => Record<string, unknown>;
25
- responseTransform: (bodyStr: string) => string;
25
+ responseTransform: (body: Record<string, unknown>) => Record<string, unknown>;
26
26
  streamTransformClass: new (model: string) => Transform;
27
27
  }): FormatConverter;
@@ -6,8 +6,8 @@ export function createConverter(deps) {
6
6
  transformRequest(body) {
7
7
  return deps.requestTransform(body);
8
8
  },
9
- transformResponse(bodyStr) {
10
- return deps.responseTransform(bodyStr);
9
+ transformResponse(body) {
10
+ return deps.responseTransform(body);
11
11
  },
12
12
  createStreamTransform(model) {
13
13
  return new deps.streamTransformClass(model);
@@ -251,25 +251,24 @@ export async function executeFailoverLoop(ctx, errors, deps, upstreamPath, adapt
251
251
  try {
252
252
  const parsed = JSON.parse(bodyStr);
253
253
  if (parsed.type === "error" || parsed.error) {
254
- return formatRegistry.transformError(bodyStr, provider.api_type, ctx.apiType);
254
+ return formatRegistry.transformError(parsed, provider.api_type, ctx.apiType);
255
255
  }
256
- let transformed = formatRegistry.transformResponse(bodyStr, provider.api_type, ctx.apiType);
256
+ let transformed = formatRegistry.transformResponse(parsed, provider.api_type, ctx.apiType);
257
257
  if (pluginRegistry && !isStream) {
258
258
  try {
259
- const respObj = JSON.parse(transformed);
260
259
  const respCtx = {
261
- response: respObj,
260
+ response: transformed,
262
261
  sourceApiType: provider.api_type,
263
262
  targetApiType: clientApiType,
264
263
  provider: { id: provider.id, name: provider.name, base_url: provider.base_url, api_type: provider.api_type },
265
264
  };
266
265
  pluginRegistry.applyBeforeResponse(respCtx);
267
266
  pluginRegistry.applyAfterResponse(respCtx);
268
- transformed = JSON.stringify(respCtx.response);
267
+ transformed = respCtx.response;
269
268
  }
270
269
  catch { /* response hooks best-effort */ } // eslint-disable-line taste/no-silent-catch
271
270
  }
272
- return transformed;
271
+ return JSON.stringify(transformed);
273
272
  }
274
273
  catch (err) {
275
274
  request.log.error({ err }, "responseTransform failed");
@@ -2,12 +2,10 @@ import Database from "better-sqlite3";
2
2
  export declare class UsageWindowTracker {
3
3
  private db;
4
4
  constructor(db: Database.Database);
5
- /** 请求成功后调用,按需创建新窗口 */
5
+ /** 请求成功后调用,按需创建新窗口(前向模式:窗口 = [now, now + 5h]) */
6
6
  recordRequest(providerId: string, routerKeyId?: string): void;
7
- /** 启动时按活跃 provider 补齐缺失的窗口 */
7
+ /** 启动时按活跃 provider 补齐缺失的窗口(每个 provider 仅创建一个前向窗口) */
8
8
  reconcileOnStartup(): void;
9
- /** 为单个 provider 补齐窗口 */
9
+ /** 为单个 provider 补齐窗口:无窗口时基于最新 log 创建前向窗口 */
10
10
  private reconcileProvider;
11
- /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
12
- private backfillProviderWindows;
13
11
  }
@@ -4,71 +4,56 @@ import { getAllProviders } from "../../db/providers.js";
4
4
  import { toSqliteDatetime, parseSqliteDatetime as parseDate } from "../../utils/datetime.js";
5
5
  // eslint-disable-next-line no-magic-numbers
6
6
  const WINDOW_DURATION_MS = 5 * 3600_000;
7
+ const MS_PER_MINUTE = 60000;
8
+ // 过期判断最小间隔(毫秒),同分钟内不重复创建窗口
9
+ const WINDOW_GRACE_PERIOD_MS = MS_PER_MINUTE;
7
10
  export class UsageWindowTracker {
8
11
  db;
9
12
  constructor(db) {
10
13
  this.db = db;
11
14
  }
12
- /** 请求成功后调用,按需创建新窗口 */
15
+ /** 请求成功后调用,按需创建新窗口(前向模式:窗口 = [now, now + 5h]) */
13
16
  recordRequest(providerId, routerKeyId) {
14
17
  const now = new Date();
15
18
  const latest = getLatestWindow(this.db, routerKeyId, providerId);
16
- if (!latest || now > parseDate(latest.end_time)) {
17
- const startTime = truncateToMinute(now);
18
- insertWindow(this.db, {
19
- id: randomUUID(),
20
- router_key_id: routerKeyId ?? null,
21
- provider_id: providerId,
22
- start_time: toSqliteDatetime(startTime),
23
- end_time: toSqliteDatetime(new Date(startTime.getTime() + WINDOW_DURATION_MS)),
24
- });
19
+ // 无窗口 创建。有窗口但已过期(end_time <= now)且跨分钟 新窗口。
20
+ // 同分钟内的快速调用不重复创建,避免同一分钟内的多次请求产生多个窗口。
21
+ if (!latest) {
22
+ createForwardWindow(this.db, now, routerKeyId, providerId);
23
+ }
24
+ else if (parseDate(latest.end_time) <= now
25
+ && now.getTime() - parseDate(latest.end_time).getTime() >= WINDOW_GRACE_PERIOD_MS) {
26
+ createForwardWindow(this.db, now, routerKeyId, providerId);
25
27
  }
26
28
  }
27
- /** 启动时按活跃 provider 补齐缺失的窗口 */
29
+ /** 启动时按活跃 provider 补齐缺失的窗口(每个 provider 仅创建一个前向窗口) */
28
30
  reconcileOnStartup() {
29
31
  const providers = getAllProviders(this.db).filter((p) => p.is_active);
30
32
  for (const provider of providers) {
31
33
  this.reconcileProvider(provider.id);
32
34
  }
33
35
  }
34
- /** 为单个 provider 补齐窗口 */
36
+ /** 为单个 provider 补齐窗口:无窗口时基于最新 log 创建前向窗口 */
35
37
  reconcileProvider(providerId) {
36
38
  const latest = getLatestWindow(this.db, undefined, providerId);
37
- const lastLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at DESC LIMIT 1").get(providerId);
38
- if (!lastLog)
39
+ if (latest)
39
40
  return;
40
- if (!latest) {
41
- const firstLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at ASC LIMIT 1").get(providerId);
42
- if (!firstLog)
43
- return;
44
- const truncated = truncateToMinute(parseDate(firstLog.created_at));
45
- this.backfillProviderWindows(providerId, truncated);
46
- return;
47
- }
48
- this.backfillProviderWindows(providerId, parseDate(latest.end_time));
49
- }
50
- /** 从 baseTime 开始,每 5h 一个窗口,直到覆盖 lastLogTime */
51
- backfillProviderWindows(providerId, baseTime) {
52
41
  const lastLog = this.db.prepare("SELECT created_at FROM request_logs WHERE provider_id = ? ORDER BY created_at DESC LIMIT 1").get(providerId);
53
42
  if (!lastLog)
54
43
  return;
55
- const lastLogTime = parseDate(lastLog.created_at);
56
- let windowStart = baseTime;
57
- while (windowStart < lastLogTime) {
58
- const windowEnd = new Date(windowStart.getTime() + WINDOW_DURATION_MS);
59
- insertWindow(this.db, {
60
- id: randomUUID(),
61
- router_key_id: null,
62
- provider_id: providerId,
63
- start_time: toSqliteDatetime(windowStart),
64
- end_time: toSqliteDatetime(windowEnd),
65
- });
66
- windowStart = windowEnd;
67
- }
44
+ const anchor = parseDate(lastLog.created_at);
45
+ createForwardWindow(this.db, anchor, undefined, providerId);
68
46
  }
69
47
  }
70
- function truncateToMinute(date) {
71
- const d = new Date(date);
72
- d.setSeconds(0, 0);
73
- return d;
48
+ /** 创建 5h 前向窗口:窗口 = [start, start + 5h],start 向下取整到分钟 */
49
+ function createForwardWindow(db, anchor, routerKeyId, providerId) {
50
+ const start = new Date(Math.floor(anchor.getTime() / MS_PER_MINUTE) * MS_PER_MINUTE);
51
+ const end = new Date(start.getTime() + WINDOW_DURATION_MS);
52
+ insertWindow(db, {
53
+ id: randomUUID(),
54
+ router_key_id: routerKeyId ?? null,
55
+ provider_id: providerId ?? null,
56
+ start_time: toSqliteDatetime(start),
57
+ end_time: toSqliteDatetime(end),
58
+ });
74
59
  }
@@ -66,6 +66,8 @@ export function convertMessagesOA2Ant(messages) {
66
66
  // tool_calls → tool_use blocks
67
67
  if (msg.tool_calls) {
68
68
  for (const tc of msg.tool_calls) {
69
+ if (!tc.function)
70
+ continue;
69
71
  const input = parseToolArguments(tc.function.arguments);
70
72
  blocks.push({ type: "tool_use", id: sanitizeToolUseId(tc.id), name: tc.function.name, input });
71
73
  }
@@ -160,10 +162,28 @@ export function convertMessagesAnt2OA(system, messages) {
160
162
  if (!content?.length)
161
163
  continue;
162
164
  const textParts = content.filter((b) => b.type === "text");
165
+ const imageParts = content.filter((b) => b.type === "image");
163
166
  const toolResults = content.filter((b) => b.type === "tool_result");
164
- if (textParts.length > 0) {
167
+ // 仅有 text 简单字符串 content
168
+ if (textParts.length > 0 && imageParts.length === 0) {
165
169
  result.push({ role: "user", content: textParts.map(b => b.text).join("") });
166
170
  }
171
+ else if (textParts.length > 0 || imageParts.length > 0) {
172
+ // 有 image 或混合 → content part 数组
173
+ const parts = [];
174
+ for (const tb of textParts) {
175
+ parts.push({ type: "text", text: tb.text });
176
+ }
177
+ for (const ib of imageParts) {
178
+ if (ib.source.type === "base64" && ib.source.data) {
179
+ parts.push({ type: "image_url", image_url: { url: `data:${ib.source.media_type};base64,${ib.source.data}` } });
180
+ }
181
+ else if (ib.source.type === "url" && ib.source.url) {
182
+ parts.push({ type: "image_url", image_url: { url: ib.source.url } });
183
+ }
184
+ }
185
+ result.push({ role: "user", content: parts });
186
+ }
167
187
  for (const tr of toolResults) {
168
188
  let toolCallId = tr.tool_use_id;
169
189
  // 空 tool_use_id → 按顺序配对到预生成的 UUID
@@ -198,7 +218,7 @@ export function convertMessagesAnt2OA(system, messages) {
198
218
  oaiMsg.tool_calls = toolBlocks.map((b, i) => ({
199
219
  id: b.id || (idMap ? idMap.get(i) || randomUUID() : randomUUID()),
200
220
  type: "function",
201
- function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
221
+ function: { name: b.name ?? "unknown", arguments: JSON.stringify(b.input ?? {}) },
202
222
  }));
203
223
  }
204
224
  if (oaiMsg.content || oaiMsg.tool_calls) {
@@ -55,17 +55,50 @@ export function responsesToChatRequest(body) {
55
55
  result.tools = chatTools;
56
56
  }
57
57
  }
58
- // tool_choice — compatible between Chat and Responses
58
+ // tool_choice — normalize {type:"tool"} from Cursor IDE, then pass through
59
59
  if (req.tool_choice != null) {
60
- result.tool_choice = req.tool_choice;
60
+ const tc = req.tool_choice;
61
+ if (typeof tc === "object" && tc !== null) {
62
+ const obj = tc;
63
+ if (obj.type === "tool" && !obj.name) {
64
+ result.tool_choice = "required";
65
+ }
66
+ else {
67
+ result.tool_choice = tc;
68
+ }
69
+ }
70
+ else {
71
+ result.tool_choice = tc;
72
+ }
61
73
  }
62
74
  // reasoning — pass through (both use {effort?, max_tokens?})
63
75
  if (req.reasoning != null) {
64
76
  result.reasoning = req.reasoning;
65
77
  }
66
- // text.format → response_format
78
+ // text.format → response_format (json_schema 结构差异需转换)
67
79
  if (req.text?.format != null) {
68
- result.response_format = req.text.format;
80
+ const format = req.text.format;
81
+ if (format.type === "json_schema") {
82
+ result.response_format = {
83
+ type: "json_schema",
84
+ json_schema: {
85
+ name: format.name ?? "response_schema",
86
+ schema: format.schema ?? {},
87
+ strict: format.strict ?? false,
88
+ },
89
+ };
90
+ }
91
+ else {
92
+ result.response_format = format;
93
+ }
94
+ }
95
+ // parallel_tool_calls — pass through
96
+ if (req.parallel_tool_calls != null) {
97
+ result.parallel_tool_calls = req.parallel_tool_calls;
98
+ }
99
+ // metadata.user_id → user
100
+ if (req.metadata?.user_id) {
101
+ result.user = req.metadata.user_id;
69
102
  }
70
103
  // stream_options
71
104
  if (req.stream_options != null) {
@@ -135,6 +168,10 @@ function convertResponsesInputToChatMessages(input, messages) {
135
168
  if (pendingFnCalls.length > 0) {
136
169
  flushFunctionCalls(messages, pendingFnCalls);
137
170
  }
171
+ // Post-process: 确保 system/developer 消息不出现在 assistant(tool_calls) 和 tool 之间
172
+ // Responses API 允许 function_call 和 function_call_output 之间插入 developer 消息,
173
+ // 但 Chat Completions 格式要求 assistant(tool_calls) 后必须紧跟 tool 消息。
174
+ reorderMessagesAroundToolCalls(messages);
138
175
  }
139
176
  /**
140
177
  * Flush accumulated function_call tool_calls into a single assistant message.
@@ -147,6 +184,81 @@ function flushFunctionCalls(messages, pending) {
147
184
  });
148
185
  pending.length = 0;
149
186
  }
187
+ /**
188
+ * 将 system/developer 消息从 assistant(tool_calls) 和 tool 之间移走。
189
+ *
190
+ * Responses API 允许 function_call 和 function_call_output 之间插入 developer 消息,
191
+ * 但 Chat Completions 格式要求 assistant(tool_calls) 后必须紧跟对应的 tool 消息。
192
+ *
193
+ * 算法:遍历 messages,当发现 assistant(tool_calls) 后紧跟的非 tool 消息时,
194
+ * 收集这些非 tool 消息,跳过后续的 tool 消息,然后在 tool 消息之后插入收集的非 tool 消息。
195
+ */
196
+ function reorderMessagesAroundToolCalls(messages) {
197
+ const toolCallIds = new Set();
198
+ // 先收集所有 assistant tool_calls 的 ID,用于判断 tool 消息是否属于该批次
199
+ for (const msg of messages) {
200
+ if (msg.role === "assistant" && msg.tool_calls) {
201
+ const calls = msg.tool_calls;
202
+ for (const tc of calls) {
203
+ if (typeof tc.id === "string")
204
+ toolCallIds.add(tc.id);
205
+ }
206
+ }
207
+ }
208
+ let i = 0;
209
+ while (i < messages.length) {
210
+ const msg = messages[i];
211
+ // 找到 assistant(tool_calls) 消息
212
+ if (msg.role === "assistant" && msg.tool_calls) {
213
+ const calls = msg.tool_calls;
214
+ const batchIds = new Set(calls.map((tc) => tc.id));
215
+ // 检查紧跟的消息是否为 tool 消息
216
+ let j = i + 1;
217
+ const pendingNonTool = [];
218
+ while (j < messages.length) {
219
+ const next = messages[j];
220
+ if (next.role === "tool" && batchIds.has(next.tool_call_id)) {
221
+ // 这是属于当前 assistant 的 tool 消息,停止扫描
222
+ break;
223
+ }
224
+ if (next.role === "system" || next.role === "developer") {
225
+ // 收集需要延后的 system/developer 消息
226
+ pendingNonTool.push(next);
227
+ j++;
228
+ }
229
+ else if (next.role === "tool") {
230
+ // 属于其他 assistant 的 tool 消息,停止
231
+ break;
232
+ }
233
+ else {
234
+ // 其他角色消息(user/assistant),停止
235
+ break;
236
+ }
237
+ }
238
+ if (pendingNonTool.length > 0) {
239
+ // 从 messages 中删除这些非 tool 消息
240
+ messages.splice(i + 1, pendingNonTool.length);
241
+ // 找到属于当前 assistant 的所有 tool 消息的末尾位置
242
+ let toolEnd = i + 1; // splice 后 j 可能已经变了
243
+ while (toolEnd < messages.length) {
244
+ const candidate = messages[toolEnd];
245
+ if (candidate.role === "tool" && batchIds.has(candidate.tool_call_id)) {
246
+ toolEnd++;
247
+ }
248
+ else {
249
+ break;
250
+ }
251
+ }
252
+ // 在 tool 消息之后插入收集的非 tool 消息
253
+ messages.splice(toolEnd, 0, ...pendingNonTool);
254
+ // 跳过处理过的消息
255
+ i = toolEnd + pendingNonTool.length;
256
+ continue;
257
+ }
258
+ }
259
+ i++;
260
+ }
261
+ }
150
262
  /**
151
263
  * Extract text content from a ResponseInputMessage.
152
264
  */
@@ -223,9 +335,34 @@ export function chatToResponsesRequest(body) {
223
335
  if (req.reasoning != null) {
224
336
  result.reasoning = req.reasoning;
225
337
  }
226
- // response_format → text.format
338
+ // response_format → text.format (json_schema 结构差异需转换)
227
339
  if (req.response_format != null) {
228
- result.text = { format: req.response_format };
340
+ const rf = req.response_format;
341
+ if (rf.type === "json_schema" && rf.json_schema) {
342
+ const js = rf.json_schema;
343
+ result.text = {
344
+ format: {
345
+ type: "json_schema",
346
+ name: js.name ?? "response_schema",
347
+ schema: js.schema,
348
+ strict: js.strict,
349
+ },
350
+ };
351
+ }
352
+ else if (rf.type === "json_object") {
353
+ result.text = { format: { type: "json_object" } };
354
+ }
355
+ else {
356
+ result.text = { format: rf };
357
+ }
358
+ }
359
+ // parallel_tool_calls — pass through
360
+ if (req.parallel_tool_calls != null) {
361
+ result.parallel_tool_calls = req.parallel_tool_calls;
362
+ }
363
+ // user → metadata.user_id
364
+ if (req.user) {
365
+ result.metadata = { user_id: req.user };
229
366
  }
230
367
  // stream_options
231
368
  if (req.stream_options != null) {
@@ -259,12 +396,41 @@ function convertChatMessagesToResponsesInput(messages) {
259
396
  const items = [];
260
397
  for (const msg of messages) {
261
398
  if (msg.role === "user") {
262
- const text = typeof msg.content === "string" ? msg.content : (msg.content ?? "");
263
- items.push({
264
- type: "message",
265
- role: "user",
266
- content: [{ type: "input_text", text }],
267
- });
399
+ const raw = msg.content;
400
+ if (typeof raw === "string") {
401
+ items.push({
402
+ type: "message",
403
+ role: "user",
404
+ content: [{ type: "input_text", text: raw }],
405
+ });
406
+ }
407
+ else if (Array.isArray(raw)) {
408
+ const parts = [];
409
+ for (const part of raw) {
410
+ if (typeof part === "object" && part !== null) {
411
+ const p = part;
412
+ if (p.type === "text" && p.text != null) {
413
+ parts.push({ type: "input_text", text: p.text });
414
+ }
415
+ else if (p.type === "image_url") {
416
+ parts.push({
417
+ type: "input_image",
418
+ image_url: p.image_url?.url ?? "",
419
+ });
420
+ }
421
+ }
422
+ }
423
+ if (parts.length > 0) {
424
+ items.push({ type: "message", role: "user", content: parts });
425
+ }
426
+ }
427
+ else if (raw != null) {
428
+ items.push({
429
+ type: "message",
430
+ role: "user",
431
+ content: [{ type: "input_text", text: JSON.stringify(raw) }],
432
+ });
433
+ }
268
434
  }
269
435
  else if (msg.role === "assistant") {
270
436
  // Text content → assistant message with output_text