ldrouter 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.19.1] - 2026-10-12
8
+
9
+ ### Fixed
10
+
11
+ - **Streamed requests to OpenAI-compatible providers logged `Tokens 0`.** Such providers omit `usage` from a streamed answer unless the caller opts in with `stream_options.include_usage`, and the gateway dropped that field when converting the client body to the canonical request — so the upstream was never asked and every streamed request to those providers persisted 0 tokens. Upstream resale platforms read that as a zero-usage request and flag the key (`zero_usage_rewritten`). The gateway now always sends `stream_options: { include_usage: true }` on streamed OpenAI-compatible upstream calls, so request logs, statistics and the dashboard carry real token counts. Anthropic-compatible providers are unaffected: `/v1/messages` reports usage unconditionally. Non-streaming calls already carried usage in the response body.
12
+
13
+ - **Statistics failed with `Statistics ranges overlapping compacted history must include complete UTC days.`** A day was treated as compacted history as soon as it had a row in `daily_model_stats` — but that aggregate is written for every logged request, so every day with traffic has one. Any range that clipped such a day was refused with 400, and because the preset windows started at "now minus N days" they always clipped their own first day: the Statistics page showed only "Failed to load statistics" for Today, 7 days and 30 days alike once a day's raw detail had been pruned by retention or left behind by a compact restore. A day is now served from its aggregate only when its raw detail is genuinely gone (it holds fewer raw rows than it logged), and the preset windows are aligned to UTC day boundaries so the day they start on is whole rather than clipped.
14
+
7
15
  ## [1.19.0] - 2026-10-12
8
16
 
9
17
  ### Added
@@ -78,6 +78,7 @@ export function openAIToCanonical(req) {
78
78
  maxOutputTokens: req.max_tokens,
79
79
  stop: Array.isArray(req.stop) ? req.stop : req.stop ? [req.stop] : undefined,
80
80
  stream: Boolean(req.stream),
81
+ streamIncludeUsage: Boolean(req.stream_options?.include_usage),
81
82
  responseFormat,
82
83
  reasoning: req.reasoning_effort ? { effort: req.reasoning_effort } : undefined,
83
84
  };
@@ -88,6 +89,11 @@ export function canonicalToOpenAIRequest(req, targetModel) {
88
89
  stream: req.stream,
89
90
  messages: [],
90
91
  };
92
+ // OpenAI-compatible upstreams omit `usage` from a streamed answer unless the
93
+ // request opts in. Without this the gateway persisted 0 tokens for every
94
+ // streamed request to such a provider (live: "Tokens 0" on the Requests page).
95
+ if (req.stream)
96
+ out.stream_options = { include_usage: true };
91
97
  if (req.system)
92
98
  out.messages.push({ role: 'system', content: req.system });
93
99
  for (const m of req.messages) {
@@ -4,19 +4,29 @@ import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
5
  import { GatewayError } from '../../errors.js';
6
6
  import { toSummary, loadSummaryMaps } from './requests.js';
7
+ /**
8
+ * Midnight UTC, `offsetDays` from the day containing `now`.
9
+ *
10
+ * Preset windows are day-aligned on purpose. A rolling window always clips its first day, and a
11
+ * clipped day whose raw rows were pruned cannot be reconstructed from the day-granular aggregate —
12
+ * so a compacted install answered 400 for every preset the UI can send. Aligning the window makes
13
+ * the first day whole and leaves only today partial, which is never served from an aggregate.
14
+ */
15
+ function utcDayStart(now, offsetDays) {
16
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays));
17
+ }
7
18
  const PRESETS = {
8
19
  today: () => {
9
20
  const now = new Date();
10
- const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
11
- return { from, to: now, bucket: 'hour' };
21
+ return { from: utcDayStart(now, 0), to: now, bucket: 'hour' };
12
22
  },
13
23
  '7d': () => {
14
24
  const now = new Date();
15
- return { from: new Date(now.getTime() - 7 * 24 * 3600 * 1000), to: now, bucket: 'day' };
25
+ return { from: utcDayStart(now, -6), to: now, bucket: 'day' };
16
26
  },
17
27
  '30d': () => {
18
28
  const now = new Date();
19
- return { from: new Date(now.getTime() - 30 * 24 * 3600 * 1000), to: now, bucket: 'day' };
29
+ return { from: utcDayStart(now, -29), to: now, bucket: 'day' };
20
30
  },
21
31
  };
22
32
  /**
@@ -50,10 +60,43 @@ function cacheHitRateFor(db, conds) {
50
60
  function rangeFromQuery(q) {
51
61
  if (q.preset && PRESETS[q.preset])
52
62
  return PRESETS[q.preset]();
53
- const from = q.from ? new Date(q.from) : new Date(Date.now() - 7 * 24 * 3600 * 1000);
54
- const to = q.to ? new Date(q.to) : new Date();
63
+ const now = new Date();
64
+ const from = q.from ? new Date(q.from) : utcDayStart(now, -6);
65
+ const to = q.to ? new Date(q.to) : now;
55
66
  return { from, to, bucket: q.bucket === 'hour' ? 'hour' : 'day' };
56
67
  }
68
+ /**
69
+ * UTC days whose raw request detail is gone and that therefore must be answered from the
70
+ * day-granular aggregate.
71
+ *
72
+ * Having a `daily_model_stats` row proves nothing: the aggregate is written for EVERY logged
73
+ * request, so every day with traffic has one, and treating that as "compacted" made every
74
+ * rolling preset (whose window always clips its first day) answer 400. Only the raw log
75
+ * separates the states, and only a day holding FEWER raw rows than it logged needs its
76
+ * aggregate — which is exactly what retention (cutoff mid-day) and a compact export/restore
77
+ * leave behind.
78
+ *
79
+ * Counted over whole UTC days, never the requested window: a clipped first day still has all
80
+ * of its raw rows, and comparing a window-scoped count against a whole-day aggregate would
81
+ * report every such day as pruned. `created_at` is indexed, so this is one grouped range scan.
82
+ */
83
+ function aggregateRequiredDays(db, aggregateRows) {
84
+ const loggedByDay = new Map();
85
+ for (const r of aggregateRows)
86
+ loggedByDay.set(r.day, (loggedByDay.get(r.day) ?? 0) + r.requests);
87
+ if (loggedByDay.size === 0)
88
+ return [];
89
+ // ISO-8601 UTC dates sort lexicographically, so min/max bound the whole-day scan.
90
+ const days = [...loggedByDay.keys()].sort();
91
+ const rawCounts = db
92
+ .select({ day: sql `substr(created_at,1,10)`, c: sql `COUNT(*)` })
93
+ .from(schema.requests)
94
+ .where(and(gte(schema.requests.createdAt, `${days[0]}T00:00:00.000Z`), lte(schema.requests.createdAt, `${days[days.length - 1]}T23:59:59.999Z`)))
95
+ .groupBy(sql `substr(created_at,1,10)`)
96
+ .all();
97
+ const rawByDay = new Map(rawCounts.map((r) => [r.day, Number(r.c)]));
98
+ return [...loggedByDay].filter(([day, logged]) => (rawByDay.get(day) ?? 0) < logged).map(([day]) => day);
99
+ }
57
100
  export async function registerStatsRoutes(app) {
58
101
  app.addHook('preHandler', requireAdminAuth);
59
102
  app.get('/api/admin/stats', async (req) => {
@@ -65,9 +108,12 @@ export async function registerStatsRoutes(app) {
65
108
  const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
66
109
  const utcToday = new Date().toISOString().slice(0, 10);
67
110
  const aggregateRows = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
68
- const overlappingAggregateDays = [...new Set(aggregateRows.map((r) => r.day))].filter((day) => day < utcToday && `${day}T00:00:00.000Z` <= toIso && `${day}T23:59:59.999Z` >= fromIso);
69
- const aggregateDays = overlappingAggregateDays.filter((day) => `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
70
- if (aggregateDays.length !== overlappingAggregateDays.length) {
111
+ // Only days whose raw detail is actually gone are answered from the day-granular aggregate.
112
+ // A day that still has its raw rows is served from raw, exactly like a fresh install.
113
+ const aggregateDays = aggregateRequiredDays(db, aggregateRows)
114
+ .filter((day) => day < utcToday && `${day}T00:00:00.000Z` <= toIso && `${day}T23:59:59.999Z` >= fromIso);
115
+ const clippedDays = aggregateDays.filter((day) => `${day}T00:00:00.000Z` < fromIso || `${day}T23:59:59.999Z` > toIso);
116
+ if (clippedDays.length > 0) {
71
117
  throw new GatewayError('invalid_request_error', 'Statistics ranges overlapping compacted history must include complete UTC days.', { status: 400 });
72
118
  }
73
119
  const percentilesAvailable = aggregateDays.length === 0;
@@ -306,7 +352,8 @@ export async function registerStatsRoutes(app) {
306
352
  function buildSummary(db, fromIso, toIso) {
307
353
  // For previous period, use the same aggregate/raw partition as the primary range.
308
354
  const days = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
309
- const daySet = [...new Set(days.map((x) => x.day))].filter((day) => day < new Date().toISOString().slice(0, 10) && `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
355
+ // Same rule as the primary range: a day only needs its aggregate when its raw detail is gone.
356
+ const daySet = aggregateRequiredDays(db, days).filter((day) => day < new Date().toISOString().slice(0, 10) && `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
310
357
  const aggregates = days.filter((x) => daySet.includes(x.day));
311
358
  const rawOnly = daySet.length ? and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso), sql `substr(created_at,1,10) NOT IN (${sql.join(daySet.map((day) => sql `${day}`), sql `, `)})`) : and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso));
312
359
  const agg = aggregates.reduce((a, x) => ({ total: a.total + x.requests, success: a.success + x.successfulRequests, failed: a.failed + x.failedRequests, input: a.input + x.inputTokens, output: a.output + x.outputTokens, cacheRead: a.cacheRead + x.cacheReadTokens, cacheWrite: a.cacheWrite + x.cacheWriteTokens, reasoning: a.reasoning + x.reasoningTokens, gateway: a.gateway + x.gatewayCacheHits, fallback: a.fallback + x.fallbackRequests, latency: a.latency + x.latencySuccessSumMs, latencyCount: a.latencyCount + x.latencySuccessCount, ttft: a.ttft + x.ttftSumMs, ttftCount: a.ttftCount + x.ttftCount }), { total: 0, success: 0, failed: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, gateway: 0, fallback: 0, latency: 0, latencyCount: 0, ttft: 0, ttftCount: 0 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldrouter",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "LateDev Router — lightweight self-hosted LLM gateway with admin UI",
5
5
  "type": "module",
6
6
  "license": "MIT",