tinywebui 0.1.4 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,39 +1,37 @@
1
1
  import { scope } from './scope.js';
2
-
3
2
  /** What an aux_usage row was spent on. */
4
3
  export const AUX_KIND = { CHECKPOINT: 'checkpoint' };
5
4
  import { inputTokens, outputTokens, cacheReads, tally } from '../../public/shared/usage.js';
6
-
7
- // Token and cost statistics over stored messages.
8
- // One area of the Store; see index.js.
9
-
10
5
  /** Adds one stored attribution snapshot (see harness/attribution.js) into a running total. */
11
6
  function mergeAttribution(target, snapshot) {
12
- if (!snapshot || snapshot.version !== 1) return target;
13
- target ||= { requests: 0, input: { total: 0, reported: 0, buckets: [] }, output: { total: 0, reported: 0, buckets: [] }, cached: 0 };
14
- target.requests++;
15
- for (const side of ['input', 'output']) {
16
- const value = snapshot[side];
17
- if (!value) continue;
18
- if (value.total !== null) { target[side].total += value.total; target[side].reported++; }
19
- for (const b of value.buckets || []) {
20
- let existing = target[side].buckets.find((x) => x.category === b.category && x.source === b.source && x.capability === b.capability);
21
- if (!existing) target[side].buckets.push(existing = { ...b, tokens: 0 });
22
- existing.tokens += b.tokens;
7
+ if (!snapshot || snapshot.version !== 1)
8
+ return target;
9
+ target ||= { requests: 0, input: { total: 0, reported: 0, buckets: [] }, output: { total: 0, reported: 0, buckets: [] }, cached: 0 };
10
+ target.requests++;
11
+ for (const side of ['input', 'output']) {
12
+ const value = snapshot[side];
13
+ if (!value)
14
+ continue;
15
+ if (value.total !== null) {
16
+ target[side].total += value.total;
17
+ target[side].reported++;
18
+ }
19
+ for (const b of value.buckets || []) {
20
+ let existing = target[side].buckets.find((x) => x.category === b.category && x.source === b.source && x.capability === b.capability);
21
+ if (!existing)
22
+ target[side].buckets.push(existing = { ...b, tokens: 0 });
23
+ existing.tokens += b.tokens;
24
+ }
23
25
  }
24
- }
25
- target.cached += snapshot.cached || 0;
26
- return target;
26
+ target.cached += snapshot.cached || 0;
27
+ return target;
27
28
  }
28
-
29
29
  /** Causes of a cache miss, most actionable first. */
30
30
  export const MISS = { PREFIX: 'prefix', SWITCH: 'switch', EXPIRED: 'expired', STUB: 'stub', PROVIDER: 'provider' };
31
-
32
31
  // How long an entry outlives its last use on most providers. Longer TTLs exist
33
32
  // (Anthropic's 1h, Gemini's explicit caches), so a gap past this is a likely
34
33
  // expiry, not a certain one.
35
34
  const CACHE_TTL_MS = 5 * 60 * 1000;
36
-
37
35
  /**
38
36
  * Why a round missed the cache, given the round before it in the same chat:
39
37
  * null for a hit or a round that can't be judged. A miss is less than half of
@@ -42,16 +40,20 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
42
40
  * `prefix` is the fingerprint streamTurn() stores (see chat/llm.js).
43
41
  */
44
42
  export function cacheMiss(round, prev) {
45
- const { usage, at } = round;
46
- const prefix = usage.prefix;
47
- if (!prev || !prefix || prefix.previous == null || !tally([usage]).reported) return null;
48
- if ((cacheReads(usage) ?? 0) >= prev.input / 2) return null;
49
- if (prefix.matched < prefix.previous) return prefix.stubSwap ? MISS.STUB : MISS.PREFIX;
50
- if (usage.provider && prev.provider && usage.provider !== prev.provider) return MISS.SWITCH;
51
- if (at != null && prev.at != null && at - prev.at > CACHE_TTL_MS) return MISS.EXPIRED;
52
- return MISS.PROVIDER;
43
+ const { usage, at } = round;
44
+ const prefix = usage.prefix;
45
+ if (!prev || !prefix || prefix.previous == null || !tally([usage]).reported)
46
+ return null;
47
+ if ((cacheReads(usage) ?? 0) >= prev.input / 2)
48
+ return null;
49
+ if (prefix.matched < prefix.previous)
50
+ return prefix.stubSwap ? MISS.STUB : MISS.PREFIX;
51
+ if (usage.provider && prev.provider && usage.provider !== prev.provider)
52
+ return MISS.SWITCH;
53
+ if (at != null && prev.at != null && at - prev.at > CACHE_TTL_MS)
54
+ return MISS.EXPIRED;
55
+ return MISS.PROVIDER;
53
56
  }
54
-
55
57
  /**
56
58
  * What caching saved, from costs providers already report. Within one model on
57
59
  * one upstream, every round's input cost is fresh * full + cached * cacheRate
@@ -61,73 +63,74 @@ export function cacheMiss(round, prev) {
61
63
  * `rounds`: [{ fresh, cached, inputCost }]. Returns null when it can't tell.
62
64
  */
63
65
  export function derivedCacheRates(rounds) {
64
- if (rounds.length < 2) return null;
65
- let ff = 0, fc = 0, cc = 0, fy = 0, cy = 0;
66
- for (const { fresh, cached, inputCost: y } of rounds) {
67
- ff += fresh * fresh; fc += fresh * cached; cc += cached * cached; fy += fresh * y; cy += cached * y;
68
- }
69
- const det = ff * cc - fc * fc;
70
- // All rounds at one cache ratio: the two prices can't be told apart.
71
- if (!(det > 1e-9 * ff * cc)) return null;
72
- const full = (fy * cc - fc * cy) / det;
73
- const cacheRate = (ff * cy - fc * fy) / det;
74
- if (!(full > 0) || cacheRate < -1e-12 || cacheRate >= full) return null;
75
- const fits = rounds.every(({ fresh, cached, inputCost }) =>
76
- Math.abs(fresh * full + cached * cacheRate - inputCost) <= 0.01 * inputCost + 1e-9);
77
- return fits ? { full, cache: Math.max(cacheRate, 0) } : null;
66
+ if (rounds.length < 2)
67
+ return null;
68
+ let ff = 0, fc = 0, cc = 0, fy = 0, cy = 0;
69
+ for (const { fresh, cached, inputCost: y } of rounds) {
70
+ ff += fresh * fresh;
71
+ fc += fresh * cached;
72
+ cc += cached * cached;
73
+ fy += fresh * y;
74
+ cy += cached * y;
75
+ }
76
+ const det = ff * cc - fc * fc;
77
+ // All rounds at one cache ratio: the two prices can't be told apart.
78
+ if (!(det > 1e-9 * ff * cc))
79
+ return null;
80
+ const full = (fy * cc - fc * cy) / det;
81
+ const cacheRate = (ff * cy - fc * fy) / det;
82
+ if (!(full > 0) || cacheRate < -1e-12 || cacheRate >= full)
83
+ return null;
84
+ const fits = rounds.every(({ fresh, cached, inputCost }) => Math.abs(fresh * full + cached * cacheRate - inputCost) <= 0.01 * inputCost + 1e-9);
85
+ return fits ? { full, cache: Math.max(cacheRate, 0) } : null;
78
86
  }
79
-
80
87
  /**
81
88
  * Which rounds a statistics view covers: `model` alone is every upstream that
82
89
  * served it; `provider` narrows to one ('' is rounds with none on record).
83
90
  */
84
- const matches = (filter, model, provider) =>
85
- (!filter.model || model === filter.model)
86
- && (filter.provider == null || (provider ?? '') === filter.provider);
87
-
91
+ const matches = (filter, model, provider) => (!filter.model || model === filter.model)
92
+ && (filter.provider == null || (provider ?? '') === filter.provider);
88
93
  /** Every model seen, with the upstreams that served it, for the filter. */
89
94
  function addTarget(targets, model, provider) {
90
- if (!targets.has(model)) targets.set(model, new Set());
91
- if (provider) targets.get(model).add(provider);
95
+ if (!targets.has(model))
96
+ targets.set(model, new Set());
97
+ if (provider)
98
+ targets.get(model).add(provider);
92
99
  }
93
100
  const listTargets = (targets) => [...targets].map(([model, providers]) => ({ model, providers: [...providers].sort() }))
94
- .sort((a, b) => a.model.localeCompare(b.model));
95
-
101
+ .sort((a, b) => a.model.localeCompare(b.model));
96
102
  export class UsageStore {
97
- constructor(db, deps = {}) {
98
- this.db = db;
99
- Object.assign(this, deps);
100
- }
101
-
102
- /** Usage of a request made for a chat that is not one of its turns. `kind`: AUX_KIND. */
103
- addAuxiliary(chatId, { kind, model, usage }) {
104
- this.db.prepare('INSERT INTO aux_usage (chat_id, kind, model, usage_json, created_at) VALUES (?, ?, ?, ?, ?)')
105
- .run(chatId, kind, model ?? null, JSON.stringify(usage ?? {}), Date.now());
106
- }
107
-
108
- /** Usage of one chat's auxiliary requests, oldest first. */
109
- auxiliaryFor(chatId) {
110
- return this.db.prepare('SELECT usage_json FROM aux_usage WHERE chat_id = ? ORDER BY id').all(chatId)
111
- .map((r) => JSON.parse(r.usage_json));
112
- }
113
-
114
- /**
115
- * Rolls up token usage across every assistant round that has it, bucketed
116
- * by local calendar day ('YYYY-MM-DD', via SQLite's julianday/strftime on
117
- * created_at) and by model. Buckets are computed in SQL rather than in JS
118
- * so a year of history doesn't have to be pulled across just to summarize
119
- * it. Field-name variance across providers (prompt_tokens vs input_tokens,
120
- * the several cache-token spellings) is normalized here the same way
121
- * transcript.js's tally() does client-side for a single round.
122
- *
123
- * Rows predating model/timestamp tracking have neither column set and land
124
- * in an 'unknown' day bucket instead of being dropped, so their tokens
125
- * still show up here the same way their cost already does in
126
- * usageStatistics() below.
127
- */
128
- rollup(userId, filter = {}) {
129
- const s = scope(userId, 'c.user_id');
130
- const rows = this.db.prepare(`
103
+ db;
104
+ constructor(db) {
105
+ this.db = db;
106
+ }
107
+ /** Usage of a request made for a chat that is not one of its turns. `kind`: AUX_KIND. */
108
+ addAuxiliary(chatId, { kind, model, usage }) {
109
+ this.db.prepare('INSERT INTO aux_usage (chat_id, kind, model, usage_json, created_at) VALUES (?, ?, ?, ?, ?)')
110
+ .run(chatId, kind, model ?? null, JSON.stringify(usage ?? {}), Date.now());
111
+ }
112
+ /** Usage of one chat's auxiliary requests, oldest first. */
113
+ auxiliaryFor(chatId) {
114
+ return this.db.prepare('SELECT usage_json FROM aux_usage WHERE chat_id = ? ORDER BY id').all(chatId)
115
+ .map((r) => JSON.parse(r.usage_json));
116
+ }
117
+ /**
118
+ * Rolls up token usage across every assistant round that has it, bucketed
119
+ * by local calendar day ('YYYY-MM-DD', via SQLite's julianday/strftime on
120
+ * created_at) and by model. Buckets are computed in SQL rather than in JS
121
+ * so a year of history doesn't have to be pulled across just to summarize
122
+ * it. Field-name variance across providers (prompt_tokens vs input_tokens,
123
+ * the several cache-token spellings) is normalized here the same way
124
+ * transcript.js's tally() does client-side for a single round.
125
+ *
126
+ * Rows predating model/timestamp tracking have neither column set and land
127
+ * in an 'unknown' day bucket instead of being dropped, so their tokens
128
+ * still show up here the same way their cost already does in
129
+ * usageStatistics() below.
130
+ */
131
+ rollup(userId, filter = {}) {
132
+ const s = scope(userId, 'c.user_id');
133
+ const rows = this.db.prepare(`
131
134
  SELECT CASE WHEN m.created_at IS NOT NULL
132
135
  THEN strftime('%Y-%m-%d', m.created_at / 1000, 'unixepoch')
133
136
  ELSE 'unknown' END AS day,
@@ -137,191 +140,262 @@ export class UsageStore {
137
140
  JOIN chats c ON c.id=m.chat_id
138
141
  WHERE m.usage_json IS NOT NULL AND ${s.sql}
139
142
  `).all(...s.params);
140
-
141
- const byDay = new Map();
142
- for (const row of rows) {
143
- let u;
144
- try { u = JSON.parse(row.usage_json); } catch { continue; }
145
- const inTok = inputTokens(u) ?? 0;
146
- const outTok = outputTokens(u) ?? 0;
147
- const cached = cacheReads(u) ?? 0;
148
- const model = row.model || u.model || 'unknown';
149
- if (!matches(filter, model, u.provider)) continue;
150
-
151
- const cost = u.cost != null && Number.isFinite(Number(u.cost)) ? Number(u.cost) : null;
152
- if (!byDay.has(row.day)) byDay.set(row.day, { day: row.day, in: 0, out: 0, cached: 0, models: new Map() });
153
- const d = byDay.get(row.day);
154
- d.in += inTok; d.out += outTok; d.cached += cached;
155
-
156
- if (!d.models.has(model)) d.models.set(model, { model, in: 0, out: 0, cached: 0 });
157
- const m = d.models.get(model);
158
- m.in += inTok; m.out += outTok; m.cached += cached;
159
- m.attribution = mergeAttribution(m.attribution, u.attribution);
160
- m.requests = (m.requests || 0) + 1;
161
- if (cost !== null) {
162
- d.cost = (d.cost || 0) + cost;
163
- m.cost = (m.cost || 0) + cost;
164
- d.pricedRounds = (d.pricedRounds || 0) + 1;
165
- m.pricedRounds = (m.pricedRounds || 0) + 1;
166
- }
143
+ const byDay = new Map();
144
+ for (const row of rows) {
145
+ let u;
146
+ try {
147
+ u = JSON.parse(row.usage_json);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ const inTok = inputTokens(u) ?? 0;
153
+ const outTok = outputTokens(u) ?? 0;
154
+ const cached = cacheReads(u) ?? 0;
155
+ const model = row.model || u.model || 'unknown';
156
+ if (!matches(filter, model, u.provider))
157
+ continue;
158
+ const cost = u.cost != null && Number.isFinite(Number(u.cost)) ? Number(u.cost) : null;
159
+ if (!byDay.has(row.day))
160
+ byDay.set(row.day, { day: row.day, in: 0, out: 0, cached: 0, models: new Map() });
161
+ const d = byDay.get(row.day);
162
+ d.in += inTok;
163
+ d.out += outTok;
164
+ d.cached += cached;
165
+ if (!d.models.has(model))
166
+ d.models.set(model, { model, in: 0, out: 0, cached: 0 });
167
+ const m = d.models.get(model);
168
+ m.in += inTok;
169
+ m.out += outTok;
170
+ m.cached += cached;
171
+ m.attribution = mergeAttribution(m.attribution, u.attribution);
172
+ m.requests = (m.requests || 0) + 1;
173
+ if (cost !== null) {
174
+ d.cost = (d.cost || 0) + cost;
175
+ m.cost = (m.cost || 0) + cost;
176
+ d.pricedRounds = (d.pricedRounds || 0) + 1;
177
+ m.pricedRounds = (m.pricedRounds || 0) + 1;
178
+ }
179
+ }
180
+ return [...byDay.values()]
181
+ .map((d) => ({ ...d, models: [...d.models.values()] }))
182
+ .sort((a, b) => a.day.localeCompare(b.day));
167
183
  }
168
-
169
- return [...byDay.values()]
170
- .map((d) => ({ ...d, models: [...d.models.values()] }))
171
- .sort((a, b) => a.day.localeCompare(b.day));
172
- }
173
-
174
- statistics(userId, filter = {}) {
175
- const s = scope(userId, 'c.user_id');
176
- const rows = this.db.prepare(`SELECT m.chat_id,m.seq,m.role,m.content,m.tool_calls_json,m.model,m.usage_json,m.created_at
184
+ statistics(userId, filter = {}) {
185
+ const s = scope(userId, 'c.user_id');
186
+ const rows = this.db.prepare(`SELECT m.chat_id,m.seq,m.role,m.content,m.tool_calls_json,m.model,m.usage_json,m.created_at
177
187
  FROM messages m JOIN chats c ON c.id=m.chat_id WHERE ${s.sql} ORDER BY m.chat_id,m.seq`).all(...s.params);
178
- const models = new Map();
179
- const tools = new Map();
180
- const summary = { rounds: 0, pricedRounds: 0, reportedCost: 0, completedAnswers: 0,
181
- pricedAnswers: 0, answerCostTotal: 0, answerTokens: 0, tokenAnswers: 0, toolCalls: 0 };
182
- // Hit share counts only rounds whose provider reports cache figures: one
183
- // that reports nothing would otherwise read as a 0% hit and drag it down.
184
- const cache = { rounds: 0, input: 0, cached: 0, written: 0, saved: 0, savedRounds: 0, savedCost: 0,
185
- judged: 0, misses: Object.fromEntries(Object.values(MISS).map((k) => [k, 0])) };
186
- let prevRound = null;
187
- // Per model @ upstream, for derivedCacheRates().
188
- const priced = new Map();
189
- const targets = new Map();
190
- let chatId = null;
191
- let answer = null;
192
- const finishAnswer = () => {
193
- if (!answer) return;
194
- if (answer.complete) {
195
- summary.completedAnswers++;
196
- summary.answerRoundsTotal = (summary.answerRoundsTotal || 0) + answer.rounds;
197
- if (answer.rounds && answer.pricedRounds === answer.rounds) {
198
- summary.pricedAnswers++;
199
- summary.answerCostTotal += answer.cost;
200
- }
201
- if (answer.rounds && answer.usageRounds === answer.rounds) { summary.tokenAnswers++; summary.answerTokens += answer.tokens; }
202
- }
203
- };
204
- for (const row of rows) {
205
- if (row.chat_id !== chatId) { finishAnswer(); chatId = row.chat_id; answer = null; prevRound = null; }
206
- if (row.role === 'user') { finishAnswer(); answer = { rounds:0, usageRounds:0, pricedRounds:0, cost:0, tokens:0, complete:false }; continue; }
207
- if (row.role !== 'assistant') continue;
208
- let usage = null;
209
- try { if (row.usage_json) usage = JSON.parse(row.usage_json); } catch { /* ignore malformed historic usage */ }
210
- const model = row.model || usage?.model || 'unknown';
211
- addTarget(targets, model, usage?.provider);
212
- if (!matches(filter, model, usage?.provider)) {
213
- // Still the round the next one is judged against: a filtered-out
214
- // upstream in between is exactly what makes the next one a switch.
215
- if (usage) prevRound = { input: tally([usage]).in, provider: usage.provider ?? null, at: row.created_at };
216
- continue;
217
- }
218
- summary.rounds++;
219
- if (answer) answer.rounds++;
220
- if (!models.has(model)) models.set(model, { model, in:0, out:0, cached:0, rounds:0, pricedRounds:0, cost:0 });
221
- const m = models.get(model);
222
- m.rounds++;
223
- if (row.tool_calls_json) {
224
- let calls = null;
225
- try { calls = JSON.parse(row.tool_calls_json); } catch { /* ignore malformed historic tool_calls */ }
226
- for (const call of calls || []) {
227
- const name = call?.function?.name;
228
- if (!name) continue;
229
- summary.toolCalls++;
230
- tools.set(name, (tools.get(name) || 0) + 1);
231
- }
232
- }
233
- if (usage) {
234
- const inTok = inputTokens(usage) ?? 0;
235
- const outTok = outputTokens(usage) ?? 0;
236
- const cached = cacheReads(usage) ?? 0;
237
- const costValue = usage.cost ?? usage.total_cost;
238
- const cost = costValue !== undefined && costValue !== null && Number.isFinite(Number(costValue)) ? Number(costValue) : null;
239
- m.in += inTok; m.out += outTok; m.cached += cached;
240
- const t = tally([usage]);
241
- if (t.reported) { cache.rounds++; cache.input += t.in; cache.cached += t.cached; cache.written += t.written; }
242
- if (t.reported && usage.prefix?.previous != null && prevRound) {
243
- cache.judged++;
244
- const miss = cacheMiss({ usage, at: row.created_at }, prevRound);
245
- if (miss) cache.misses[miss]++;
188
+ const models = new Map();
189
+ const tools = new Map();
190
+ const summary = { rounds: 0, pricedRounds: 0, reportedCost: 0, completedAnswers: 0,
191
+ pricedAnswers: 0, answerCostTotal: 0, answerTokens: 0, tokenAnswers: 0, toolCalls: 0 };
192
+ // Hit share counts only rounds whose provider reports cache figures: one
193
+ // that reports nothing would otherwise read as a 0% hit and drag it down.
194
+ const cache = { rounds: 0, input: 0, cached: 0, written: 0, saved: 0, savedRounds: 0, savedCost: 0,
195
+ judged: 0, misses: Object.fromEntries(Object.values(MISS).map((k) => [k, 0])) };
196
+ let prevRound = null;
197
+ // Per model @ upstream, for derivedCacheRates().
198
+ const priced = new Map();
199
+ const targets = new Map();
200
+ let chatId = null;
201
+ let answer = null;
202
+ const finishAnswer = () => {
203
+ if (!answer)
204
+ return;
205
+ if (answer.complete) {
206
+ summary.completedAnswers++;
207
+ summary.answerRoundsTotal = (summary.answerRoundsTotal || 0) + answer.rounds;
208
+ if (answer.rounds && answer.pricedRounds === answer.rounds) {
209
+ summary.pricedAnswers++;
210
+ summary.answerCostTotal += answer.cost;
211
+ }
212
+ if (answer.rounds && answer.usageRounds === answer.rounds) {
213
+ summary.tokenAnswers++;
214
+ summary.answerTokens += answer.tokens;
215
+ }
216
+ }
217
+ };
218
+ for (const row of rows) {
219
+ if (row.chat_id !== chatId) {
220
+ finishAnswer();
221
+ chatId = row.chat_id;
222
+ answer = null;
223
+ prevRound = null;
224
+ }
225
+ if (row.role === 'user') {
226
+ finishAnswer();
227
+ answer = { rounds: 0, usageRounds: 0, pricedRounds: 0, cost: 0, tokens: 0, complete: false };
228
+ continue;
229
+ }
230
+ if (row.role !== 'assistant')
231
+ continue;
232
+ let usage = null;
233
+ try {
234
+ if (row.usage_json)
235
+ usage = JSON.parse(row.usage_json);
236
+ }
237
+ catch { /* ignore malformed historic usage */ }
238
+ const model = row.model || usage?.model || 'unknown';
239
+ addTarget(targets, model, usage?.provider);
240
+ if (!matches(filter, model, usage?.provider)) {
241
+ // Still the round the next one is judged against: a filtered-out
242
+ // upstream in between is exactly what makes the next one a switch.
243
+ if (usage)
244
+ prevRound = { input: tally([usage]).in, provider: usage.provider ?? null, at: row.created_at };
245
+ continue;
246
+ }
247
+ summary.rounds++;
248
+ if (answer)
249
+ answer.rounds++;
250
+ if (!models.has(model))
251
+ models.set(model, { model, in: 0, out: 0, cached: 0, rounds: 0, pricedRounds: 0, cost: 0 });
252
+ const m = models.get(model);
253
+ m.rounds++;
254
+ if (row.tool_calls_json) {
255
+ let calls = null;
256
+ try {
257
+ calls = JSON.parse(row.tool_calls_json);
258
+ }
259
+ catch { /* ignore malformed historic tool_calls */ }
260
+ for (const call of calls || []) {
261
+ const name = call?.function?.name;
262
+ if (!name)
263
+ continue;
264
+ summary.toolCalls++;
265
+ tools.set(name, (tools.get(name) || 0) + 1);
266
+ }
267
+ }
268
+ if (usage) {
269
+ const inTok = inputTokens(usage) ?? 0;
270
+ const outTok = outputTokens(usage) ?? 0;
271
+ const cached = cacheReads(usage) ?? 0;
272
+ const costValue = usage.cost ?? usage.total_cost;
273
+ const cost = costValue !== undefined && costValue !== null && Number.isFinite(Number(costValue)) ? Number(costValue) : null;
274
+ m.in += inTok;
275
+ m.out += outTok;
276
+ m.cached += cached;
277
+ const t = tally([usage]);
278
+ if (t.reported) {
279
+ cache.rounds++;
280
+ cache.input += t.in;
281
+ cache.cached += t.cached;
282
+ cache.written += t.written;
283
+ }
284
+ if (t.reported && usage.prefix?.previous != null && prevRound) {
285
+ cache.judged++;
286
+ const miss = cacheMiss({ usage, at: row.created_at }, prevRound);
287
+ if (miss)
288
+ cache.misses[miss]++;
289
+ }
290
+ prevRound = { input: t.in, provider: usage.provider ?? null, at: row.created_at };
291
+ const inputCost = Number(usage.cost_details?.upstream_inference_prompt_cost);
292
+ const key = `${model}\u0000${usage.provider ?? ''}`;
293
+ if (!priced.has(key))
294
+ priced.set(key, { model, provider: usage.provider ?? null, rounds: [], cost: 0, discount: 0, discounted: 0 });
295
+ const g = priced.get(key);
296
+ if (t.reported && Number.isFinite(inputCost) && inputCost > 0)
297
+ g.rounds.push({ fresh: t.in - t.cached, cached: t.cached, inputCost, cost });
298
+ if (usage.cache_discount != null && Number.isFinite(Number(usage.cache_discount))) {
299
+ g.discounted++;
300
+ g.discount += Number(usage.cache_discount);
301
+ g.cost += cost ?? 0;
302
+ }
303
+ if (cost !== null) {
304
+ m.cost += cost;
305
+ m.pricedRounds++;
306
+ summary.pricedRounds++;
307
+ summary.reportedCost += cost;
308
+ }
309
+ if (answer) {
310
+ if (inputTokens(usage) !== null && outputTokens(usage) !== null)
311
+ answer.usageRounds++;
312
+ answer.tokens += inTok + outTok;
313
+ if (cost !== null) {
314
+ answer.pricedRounds++;
315
+ answer.cost += cost;
316
+ }
317
+ }
318
+ }
319
+ if (!row.tool_calls_json && String(row.content || '').trim()) {
320
+ if (answer)
321
+ answer.complete = true;
322
+ }
246
323
  }
247
- prevRound = { input: t.in, provider: usage.provider ?? null, at: row.created_at };
248
- const inputCost = Number(usage.cost_details?.upstream_inference_prompt_cost);
249
- const key = `${model}\u0000${usage.provider ?? ''}`;
250
- if (!priced.has(key)) priced.set(key, { model, provider: usage.provider ?? null, rounds: [], cost: 0, discount: 0, discounted: 0 });
251
- const g = priced.get(key);
252
- if (t.reported && Number.isFinite(inputCost) && inputCost > 0) g.rounds.push({ fresh: t.in - t.cached, cached: t.cached, inputCost, cost });
253
- if (usage.cache_discount != null && Number.isFinite(Number(usage.cache_discount))) { g.discounted++; g.discount += Number(usage.cache_discount); g.cost += cost ?? 0; }
254
- if (cost !== null) { m.cost += cost; m.pricedRounds++; summary.pricedRounds++; summary.reportedCost += cost; }
255
- if (answer) {
256
- if (inputTokens(usage) !== null && outputTokens(usage) !== null) answer.usageRounds++;
257
- answer.tokens += inTok + outTok;
258
- if (cost !== null) { answer.pricedRounds++; answer.cost += cost; }
324
+ finishAnswer();
325
+ // A provider's own figure wins; otherwise derive it from what it charged.
326
+ const rates = [];
327
+ for (const g of priced.values()) {
328
+ if (g.discounted) {
329
+ cache.saved += g.discount;
330
+ cache.savedCost += g.cost;
331
+ cache.savedRounds += g.discounted;
332
+ continue;
333
+ }
334
+ const r = derivedCacheRates(g.rounds);
335
+ if (!r)
336
+ continue;
337
+ const cached = g.rounds.reduce((n, x) => n + x.cached, 0);
338
+ cache.saved += cached * (r.full - r.cache);
339
+ cache.savedCost += g.rounds.reduce((n, x) => n + (x.cost ?? 0), 0);
340
+ cache.savedRounds += g.rounds.length;
341
+ rates.push({ model: g.model, provider: g.provider, full: r.full, cache: r.cache });
259
342
  }
260
- }
261
- if (!row.tool_calls_json && String(row.content || '').trim()) {
262
- if (answer) answer.complete = true;
263
- }
264
- }
265
- finishAnswer();
266
- // A provider's own figure wins; otherwise derive it from what it charged.
267
- const rates = [];
268
- for (const g of priced.values()) {
269
- if (g.discounted) { cache.saved += g.discount; cache.savedCost += g.cost; cache.savedRounds += g.discounted; continue; }
270
- const r = derivedCacheRates(g.rounds);
271
- if (!r) continue;
272
- const cached = g.rounds.reduce((n, x) => n + x.cached, 0);
273
- cache.saved += cached * (r.full - r.cache);
274
- cache.savedCost += g.rounds.reduce((n, x) => n + (x.cost ?? 0), 0);
275
- cache.savedRounds += g.rounds.length;
276
- rates.push({ model: g.model, provider: g.provider, full: r.full, cache: r.cache });
277
- }
278
- // Kept out of rounds and answers: these are not turns, but they are spent.
279
- const aux = { requests: 0, pricedRequests: 0, cost: 0 };
280
- for (const row of this.db.prepare(`SELECT a.model, a.usage_json FROM aux_usage a
343
+ // Kept out of rounds and answers: these are not turns, but they are spent.
344
+ const aux = { requests: 0, pricedRequests: 0, cost: 0 };
345
+ for (const row of this.db.prepare(`SELECT a.model, a.usage_json FROM aux_usage a
281
346
  JOIN chats c ON c.id=a.chat_id WHERE ${s.sql}`).all(...s.params)) {
282
- let usage = null;
283
- try { usage = JSON.parse(row.usage_json); } catch { continue; }
284
- const model = row.model || usage.model || 'unknown';
285
- addTarget(targets, model, usage.provider);
286
- if (!matches(filter, model, usage.provider)) continue;
287
- aux.requests++;
288
- const costValue = usage.cost ?? usage.total_cost;
289
- if (costValue != null && Number.isFinite(Number(costValue))) { aux.pricedRequests++; aux.cost += Number(costValue); }
347
+ let usage = null;
348
+ try {
349
+ usage = JSON.parse(row.usage_json);
350
+ }
351
+ catch {
352
+ continue;
353
+ }
354
+ const model = row.model || usage.model || 'unknown';
355
+ addTarget(targets, model, usage.provider);
356
+ if (!matches(filter, model, usage.provider))
357
+ continue;
358
+ aux.requests++;
359
+ const costValue = usage.cost ?? usage.total_cost;
360
+ if (costValue != null && Number.isFinite(Number(costValue))) {
361
+ aux.pricedRequests++;
362
+ aux.cost += Number(costValue);
363
+ }
364
+ }
365
+ return {
366
+ targets: listTargets(targets),
367
+ auxiliary: { requests: aux.requests, cost: aux.pricedRequests ? aux.cost : null },
368
+ cache: {
369
+ reportedRounds: cache.rounds,
370
+ input: cache.input,
371
+ cached: cache.cached,
372
+ written: cache.written,
373
+ hitShare: cache.input ? cache.cached / cache.input : null,
374
+ saved: cache.savedRounds ? cache.saved : null,
375
+ // Share of what those rounds would have cost uncached.
376
+ savedShare: cache.savedRounds && cache.saved + cache.savedCost > 0 ? cache.saved / (cache.saved + cache.savedCost) : null,
377
+ rates,
378
+ savedRounds: cache.savedRounds,
379
+ // Rounds with a previous request to compare against; the rest (a
380
+ // chat's first round, the first after a restart, old rows) are unknown.
381
+ judged: cache.judged,
382
+ misses: cache.misses
383
+ },
384
+ rounds: summary.rounds,
385
+ pricedRounds: summary.pricedRounds,
386
+ unpricedRounds: summary.rounds - summary.pricedRounds,
387
+ reportedCost: summary.pricedRounds ? summary.reportedCost : null,
388
+ averageRoundCost: summary.pricedRounds ? summary.reportedCost / summary.pricedRounds : null,
389
+ completedAnswers: summary.completedAnswers,
390
+ pricedAnswers: summary.pricedAnswers,
391
+ averageAnswerCost: summary.pricedAnswers ? summary.answerCostTotal / summary.pricedAnswers : null,
392
+ averageAnswerTokens: summary.tokenAnswers ? summary.answerTokens / summary.tokenAnswers : null,
393
+ averageRoundsPerAnswer: summary.completedAnswers ? summary.answerRoundsTotal / summary.completedAnswers : null,
394
+ toolCalls: summary.toolCalls,
395
+ models: [...models.values()].map((m) => ({ ...m, cost: m.pricedRounds ? m.cost : null }))
396
+ .sort((a, b) => b.in + b.out - a.in - a.out),
397
+ tools: [...tools.entries()].map(([name, calls]) => ({ name, calls }))
398
+ .sort((a, b) => b.calls - a.calls)
399
+ };
290
400
  }
291
- return {
292
- targets: listTargets(targets),
293
- auxiliary: { requests: aux.requests, cost: aux.pricedRequests ? aux.cost : null },
294
- cache: {
295
- reportedRounds: cache.rounds,
296
- input: cache.input,
297
- cached: cache.cached,
298
- written: cache.written,
299
- hitShare: cache.input ? cache.cached / cache.input : null,
300
- saved: cache.savedRounds ? cache.saved : null,
301
- // Share of what those rounds would have cost uncached.
302
- savedShare: cache.savedRounds && cache.saved + cache.savedCost > 0 ? cache.saved / (cache.saved + cache.savedCost) : null,
303
- rates,
304
- savedRounds: cache.savedRounds,
305
- // Rounds with a previous request to compare against; the rest (a
306
- // chat's first round, the first after a restart, old rows) are unknown.
307
- judged: cache.judged,
308
- misses: cache.misses
309
- },
310
- rounds: summary.rounds,
311
- pricedRounds: summary.pricedRounds,
312
- unpricedRounds: summary.rounds - summary.pricedRounds,
313
- reportedCost: summary.pricedRounds ? summary.reportedCost : null,
314
- averageRoundCost: summary.pricedRounds ? summary.reportedCost / summary.pricedRounds : null,
315
- completedAnswers: summary.completedAnswers,
316
- pricedAnswers: summary.pricedAnswers,
317
- averageAnswerCost: summary.pricedAnswers ? summary.answerCostTotal / summary.pricedAnswers : null,
318
- averageAnswerTokens: summary.tokenAnswers ? summary.answerTokens / summary.tokenAnswers : null,
319
- averageRoundsPerAnswer: summary.completedAnswers ? summary.answerRoundsTotal / summary.completedAnswers : null,
320
- toolCalls: summary.toolCalls,
321
- models: [...models.values()].map((m) => ({ ...m, cost: m.pricedRounds ? m.cost : null }))
322
- .sort((a,b) => b.in + b.out - a.in - a.out),
323
- tools: [...tools.entries()].map(([name, calls]) => ({ name, calls }))
324
- .sort((a, b) => b.calls - a.calls)
325
- };
326
- }
327
401
  }