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
|
-
|
|
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:
|
|
25
|
+
return { from: utcDayStart(now, -6), to: now, bucket: 'day' };
|
|
16
26
|
},
|
|
17
27
|
'30d': () => {
|
|
18
28
|
const now = new Date();
|
|
19
|
-
return { from:
|
|
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
|
|
54
|
-
const
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 });
|