@aria-framework/ai 0.1.0 → 0.2.0
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/index.js +5 -0
- package/package.json +9 -3
- package/usageStore.js +270 -0
package/index.js
CHANGED
|
@@ -170,6 +170,11 @@ function createAiClient(deps = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
module.exports = {
|
|
173
|
+
// The usage counter behind every ceiling. LAZY: it needs the db-worker driver contract, which
|
|
174
|
+
// is an OPTIONAL peer — a consumer using only createAiClient/polish/facts must not be made to
|
|
175
|
+
// install a database package to require this one.
|
|
176
|
+
get createUsageStore() { return require('./usageStore').createUsageStore; },
|
|
177
|
+
get usageSchemaFor() { return require('./usageStore').schemaFor; },
|
|
173
178
|
createAiClient,
|
|
174
179
|
PROVIDERS, DEFAULTS,
|
|
175
180
|
AiError, fromFetchFailure, redact,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ai",
|
|
3
3
|
"description": "Aria App Framework — AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
"generate.js",
|
|
17
17
|
"providers/openai-compatible.js",
|
|
18
18
|
"providers/anthropic.js",
|
|
19
|
-
"browser/ai-polish.js"
|
|
19
|
+
"browser/ai-polish.js", "usageStore.js"
|
|
20
20
|
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@aria-framework/db-worker": ">=0.7.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"@aria-framework/db-worker": { "optional": true }
|
|
26
|
+
},
|
|
21
27
|
"scripts": {
|
|
22
|
-
"test": "node test/smoke.js"
|
|
28
|
+
"test": "node test/smoke.js && node test/usageStore.js"
|
|
23
29
|
}
|
|
24
30
|
}
|
package/usageStore.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model has cost — the counter behind every ceiling.
|
|
3
|
+
*
|
|
4
|
+
* EXTRACTED FROM SUPPORT101 (models/AiUsage.js + lib/ai/budget.js), which has run this in
|
|
5
|
+
* production since its migration 014. The design decisions below are its, kept because they were
|
|
6
|
+
* right and the reasoning was written down; what changed is that the app-specific parts became
|
|
7
|
+
* parameters.
|
|
8
|
+
*
|
|
9
|
+
* ── APPEND-ONLY, ONE ROW PER CALL ───────────────────────────────────────────────────────────────
|
|
10
|
+
* Not a per-day counter that gets incremented. Two reasons, both from the original:
|
|
11
|
+
* 1. A counter update is a read-modify-write, and two concurrent calls lose one of them. A
|
|
12
|
+
* ceiling backed by a lossy counter is a report, not a limit.
|
|
13
|
+
* 2. A row per call is what makes "which ticket ate the budget" answerable at all.
|
|
14
|
+
*
|
|
15
|
+
* The cost is volume, and it is NOT negligible at every scale. Support101's own comment says "the
|
|
16
|
+
* table is small — a busy day is hundreds of rows", which is true for a helpdesk (~110k rows/year)
|
|
17
|
+
* and false for log triage (~14.6M rows/year at 40k calls/day). So `rollup()` exists: detail rows
|
|
18
|
+
* are short-lived, and a daily aggregate is what survives. Append-only is preserved; only the
|
|
19
|
+
* retention of the detail changes.
|
|
20
|
+
*
|
|
21
|
+
* ── CHECKED BEFORE, RECORDED AFTER ──────────────────────────────────────────────────────────────
|
|
22
|
+
* There is no way to know what a call will cost until it returns, so a ceiling is "you are already
|
|
23
|
+
* over", never "this would take you over". The last call may overshoot by its own size. The
|
|
24
|
+
* alternative — estimate and refuse in advance — is a guess about a number that cannot be
|
|
25
|
+
* computed, and being wrong in the confident direction blocks work that would have fit. A bounded
|
|
26
|
+
* overshoot is explainable; a wrong estimate is not.
|
|
27
|
+
*
|
|
28
|
+
* ── A CEILING OF 0 IS OFF ───────────────────────────────────────────────────────────────────────
|
|
29
|
+
* Not "no tokens allowed". An install that has never opened the budget screen is unlimited rather
|
|
30
|
+
* than silently broken — the opposite default presents as "the AI stopped working" on a fresh
|
|
31
|
+
* install with no clue why.
|
|
32
|
+
*
|
|
33
|
+
* ── THE DAY IS THE APP'S DAY ────────────────────────────────────────────────────────────────────
|
|
34
|
+
* A ceiling is an allowance a person set, so it resets when their day does. Computing it in UTC
|
|
35
|
+
* rolls the budget over mid-afternoon in +02:00, which gets diagnosed as "the AI randomly stopped
|
|
36
|
+
* working". The clock is therefore injected, and defaults to the host's local date.
|
|
37
|
+
*
|
|
38
|
+
* ── WHAT STAYS IN THE APP ───────────────────────────────────────────────────────────────────────
|
|
39
|
+
* The SCOPE column and its foreign key. Support101 scopes by `ticket_id` with
|
|
40
|
+
* `ON DELETE CASCADE`, so deleting a ticket takes its usage with it — real behaviour a generic
|
|
41
|
+
* column could not carry. App 3 will scope by incident. So the package is told the column NAME and
|
|
42
|
+
* the app owns the column, its type and its constraints.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
/** YYYY-MM-DD in whatever zone the supplied clock is in. */
|
|
48
|
+
function dayOf(date) {
|
|
49
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
50
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* driver @aria-framework/db-worker driver contract (required)
|
|
56
|
+
* table detail table, default 'ai_usage'
|
|
57
|
+
* rollupTable daily aggregate, default 'ai_usage_daily'
|
|
58
|
+
* scopeColumn the app's scope column, e.g. 'ticket_id'. Omitted = no scope dimension.
|
|
59
|
+
* routeColumn the route column, e.g. 'route'. Omitted = no route dimension.
|
|
60
|
+
* now () => Date, the APP'S clock. Default: host local time.
|
|
61
|
+
*/
|
|
62
|
+
function createUsageStore(opts = {}) {
|
|
63
|
+
const driver = opts.driver;
|
|
64
|
+
if (!driver || typeof driver.run !== 'function') {
|
|
65
|
+
throw new Error('createUsageStore({ driver }): the db-worker driver contract is required');
|
|
66
|
+
}
|
|
67
|
+
const table = opts.table || 'ai_usage';
|
|
68
|
+
const rollupTable = opts.rollupTable || 'ai_usage_daily';
|
|
69
|
+
const scopeCol = opts.scopeColumn || null;
|
|
70
|
+
const routeCol = opts.routeColumn || null;
|
|
71
|
+
const now = typeof opts.now === 'function' ? opts.now : () => new Date();
|
|
72
|
+
|
|
73
|
+
/** Column list and placeholders, built once from what the app actually has. */
|
|
74
|
+
const cols = ['day', 'provider', 'model', 'calls', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
|
|
75
|
+
if (scopeCol) cols.splice(3, 0, scopeCol);
|
|
76
|
+
if (routeCol) cols.splice(3, 0, routeCol);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
table,
|
|
80
|
+
rollupTable,
|
|
81
|
+
|
|
82
|
+
/** The current day string, exposed because callers compare against it. */
|
|
83
|
+
today: () => dayOf(now()),
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Record one provider call. NEVER THROWS — see the wrapper in `budget` below; a lost counter
|
|
87
|
+
* row is a bookkeeping problem, not a reason to fail work that succeeded.
|
|
88
|
+
*/
|
|
89
|
+
async record(entry = {}) {
|
|
90
|
+
const prompt = Number(entry.promptTokens) || 0;
|
|
91
|
+
const completion = Number(entry.completionTokens) || 0;
|
|
92
|
+
const total = Number(entry.totalTokens) || (prompt + completion);
|
|
93
|
+
|
|
94
|
+
const values = [dayOf(now()), String(entry.provider || ''), String(entry.model || '')];
|
|
95
|
+
if (routeCol) values.push(entry.route == null ? null : String(entry.route));
|
|
96
|
+
if (scopeCol) values.push(entry.scope == null ? null : entry.scope);
|
|
97
|
+
values.push(1, prompt, completion, total);
|
|
98
|
+
|
|
99
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
100
|
+
const r = await driver.run(
|
|
101
|
+
`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`, values);
|
|
102
|
+
return { id: r.lastId };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Tokens spent today, across every path — background jobs and button presses alike. */
|
|
106
|
+
async tokensToday() {
|
|
107
|
+
const r = await driver.get(
|
|
108
|
+
`SELECT COALESCE(SUM(total_tokens), 0) n FROM ${table} WHERE day = ?`, [dayOf(now())]);
|
|
109
|
+
return Number(r.n) || 0;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Tokens ever spent against one scope.
|
|
114
|
+
*
|
|
115
|
+
* NOT PER DAY, deliberately, and this is load-bearing: the scope cap exists to stop one
|
|
116
|
+
* pathological thread — a hundred-message argument, or a retry loop against something the
|
|
117
|
+
* model keeps failing on — from eating the budget. A cap that reset at midnight would let
|
|
118
|
+
* exactly that happen, one day at a time, which is the failure it was written to prevent.
|
|
119
|
+
*/
|
|
120
|
+
async tokensForScope(scope) {
|
|
121
|
+
if (!scopeCol) throw new Error('createUsageStore: no scopeColumn was configured');
|
|
122
|
+
const r = await driver.get(
|
|
123
|
+
`SELECT COALESCE(SUM(total_tokens), 0) n FROM ${table} WHERE ${scopeCol} = ?`, [scope]);
|
|
124
|
+
return Number(r.n) || 0;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/** For the admin screen: recent days, newest first. */
|
|
128
|
+
async byDay(days = 14) {
|
|
129
|
+
const limit = Math.min(200, Math.max(1, Number(days) * 4));
|
|
130
|
+
const group = ['day', 'provider', 'model'].concat(routeCol ? [routeCol] : []);
|
|
131
|
+
return driver.all(
|
|
132
|
+
`SELECT ${group.join(', ')},
|
|
133
|
+
SUM(calls) calls, SUM(prompt_tokens) prompt_tokens,
|
|
134
|
+
SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens
|
|
135
|
+
FROM ${table}
|
|
136
|
+
GROUP BY ${group.join(', ')}
|
|
137
|
+
ORDER BY day DESC, total_tokens DESC
|
|
138
|
+
LIMIT ?`, [limit]);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** Totals per provider over a window, for the usage screen's per-provider table. */
|
|
142
|
+
async byProvider(sinceDay) {
|
|
143
|
+
return driver.all(
|
|
144
|
+
`SELECT provider, model,
|
|
145
|
+
SUM(calls) calls, SUM(prompt_tokens) prompt_tokens,
|
|
146
|
+
SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens
|
|
147
|
+
FROM ${table}
|
|
148
|
+
WHERE day >= ?
|
|
149
|
+
GROUP BY provider, model
|
|
150
|
+
ORDER BY total_tokens DESC`, [String(sinceDay)]);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fold completed days of detail into the daily aggregate, then drop the folded detail.
|
|
155
|
+
*
|
|
156
|
+
* THIS IS WHAT MAKES APPEND-ONLY SURVIVABLE AT VOLUME. Deliberately NOT a trigger or an upsert
|
|
157
|
+
* on the write path: keeping the hot path a plain INSERT is the entire reason two concurrent
|
|
158
|
+
* calls cannot lose each other's counts.
|
|
159
|
+
*
|
|
160
|
+
* GRANULARITY MATCHES THE DATA. The detail table carries `day`, not an hour, so this rolls up
|
|
161
|
+
* by day — an earlier draft grouped by day and wrote the result into an `hour` column, which
|
|
162
|
+
* was incoherent. A consumer that needs finer buckets (log triage, where a day is far too
|
|
163
|
+
* coarse) adds an hour column to its own detail table and configures it; inventing one here
|
|
164
|
+
* from a date would be inventing precision that was never recorded.
|
|
165
|
+
*
|
|
166
|
+
* ONLY COMPLETED DAYS are folded. Folding today would race with calls still arriving into it,
|
|
167
|
+
* and the aggregate would disagree with the detail for as long as the day lasted.
|
|
168
|
+
*
|
|
169
|
+
* IDEMPOTENT via a watermark read from the aggregate's own MAX(day) — see below. Re-running is
|
|
170
|
+
* a no-op rather than a doubling, which matters because retention can leave folded detail in
|
|
171
|
+
* place for days.
|
|
172
|
+
*
|
|
173
|
+
* @param {{keepDetailDays?: number}} o how many completed days of DETAIL to keep after
|
|
174
|
+
* folding. 0 = keep none; the aggregate is the record from then on.
|
|
175
|
+
* @returns {{folded: number, deleted: number}}
|
|
176
|
+
*/
|
|
177
|
+
async rollup(o = {}) {
|
|
178
|
+
const keepDays = Math.max(0, Number(o.keepDetailDays) || 0);
|
|
179
|
+
const group = ['provider', 'model'].concat(routeCol ? [routeCol] : []);
|
|
180
|
+
const todayStr = dayOf(now());
|
|
181
|
+
// Detail older than this is safe to delete: it has been folded AND is past retention.
|
|
182
|
+
const deleteBefore = dayOf(new Date(now().getTime() - keepDays * 86400000));
|
|
183
|
+
|
|
184
|
+
let folded = 0;
|
|
185
|
+
let deleted = 0;
|
|
186
|
+
await driver.transaction(async (tx) => {
|
|
187
|
+
// THE WATERMARK, and it is what makes this idempotent. Without it, any run with
|
|
188
|
+
// keepDetailDays > 0 re-folds detail that survived the last run and DOUBLES the aggregate
|
|
189
|
+
// — the ON CONFLICT accumulates, which is correct for new rows and catastrophic for
|
|
190
|
+
// repeats. Derived from the aggregate itself rather than stored separately: the highest
|
|
191
|
+
// day already folded is exactly `MAX(day)` there, so there is no second piece of state to
|
|
192
|
+
// keep in step.
|
|
193
|
+
const mark = await tx.get(`SELECT MAX(day) m FROM ${rollupTable}`);
|
|
194
|
+
const after = (mark && mark.m) || '';
|
|
195
|
+
|
|
196
|
+
const rows = await tx.all(
|
|
197
|
+
`SELECT day, ${group.join(', ')},
|
|
198
|
+
SUM(calls) calls, SUM(prompt_tokens) p, SUM(completion_tokens) c,
|
|
199
|
+
SUM(total_tokens) t
|
|
200
|
+
FROM ${table}
|
|
201
|
+
WHERE day < ? AND day > ?
|
|
202
|
+
GROUP BY day, ${group.join(', ')}`, [todayStr, after]);
|
|
203
|
+
|
|
204
|
+
const key = ['day', 'provider', 'model'].concat(routeCol ? [routeCol] : []);
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
const vals = [row.day, row.provider, row.model];
|
|
207
|
+
if (routeCol) vals.push(row[routeCol]);
|
|
208
|
+
await tx.run(
|
|
209
|
+
`INSERT INTO ${rollupTable} (${key.join(', ')}, calls, prompt_tokens, completion_tokens, total_tokens)
|
|
210
|
+
VALUES (${key.map(() => '?').join(', ')}, ?, ?, ?, ?)
|
|
211
|
+
ON CONFLICT(${key.join(', ')}) DO UPDATE SET
|
|
212
|
+
calls = ${rollupTable}.calls + excluded.calls,
|
|
213
|
+
prompt_tokens = ${rollupTable}.prompt_tokens + excluded.prompt_tokens,
|
|
214
|
+
completion_tokens = ${rollupTable}.completion_tokens + excluded.completion_tokens,
|
|
215
|
+
total_tokens = ${rollupTable}.total_tokens + excluded.total_tokens`,
|
|
216
|
+
vals.concat([row.calls, row.p, row.c, row.t]));
|
|
217
|
+
folded++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (folded && deleteBefore < todayStr) {
|
|
221
|
+
const res = await tx.run(`DELETE FROM ${table} WHERE day < ?`, [deleteBefore]);
|
|
222
|
+
deleted = Number(res.changes) || 0;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return { folded, deleted };
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The DDL for both tables, in the caller's dialect.
|
|
232
|
+
*
|
|
233
|
+
* The app owns its SCOPE column and any foreign key on it — Support101's `ticket_id` cascades from
|
|
234
|
+
* `tickets`, which a generic column could not do — so this emits everything EXCEPT that, and the
|
|
235
|
+
* app adds it in its own migration.
|
|
236
|
+
*/
|
|
237
|
+
function schemaFor(dialect, o = {}) {
|
|
238
|
+
const t = dialect || {
|
|
239
|
+
autoIncrementPk: () => 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
240
|
+
now: () => "datetime('now')"
|
|
241
|
+
};
|
|
242
|
+
const route = o.routeColumn ? `\n ${o.routeColumn}${' '.repeat(Math.max(1, 14 - o.routeColumn.length))}TEXT,` : '';
|
|
243
|
+
return {
|
|
244
|
+
detail: `
|
|
245
|
+
id ${t.autoIncrementPk()},
|
|
246
|
+
day TEXT NOT NULL,
|
|
247
|
+
provider TEXT NOT NULL,
|
|
248
|
+
model TEXT NOT NULL,${route}
|
|
249
|
+
calls INTEGER NOT NULL DEFAULT 1,
|
|
250
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
251
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
252
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
253
|
+
created_at TEXT NOT NULL DEFAULT (${t.now()})
|
|
254
|
+
`,
|
|
255
|
+
// The UNIQUE key is what ON CONFLICT targets — without it the rollup silently inserts
|
|
256
|
+
// duplicates instead of accumulating, and every re-run doubles the totals.
|
|
257
|
+
rollup: `
|
|
258
|
+
day TEXT NOT NULL,
|
|
259
|
+
provider TEXT NOT NULL,
|
|
260
|
+
model TEXT NOT NULL,${route}
|
|
261
|
+
calls INTEGER NOT NULL DEFAULT 0,
|
|
262
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
263
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
265
|
+
UNIQUE(day, provider, model${o.routeColumn ? ', ' + o.routeColumn : ''})
|
|
266
|
+
`
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { createUsageStore, schemaFor, _dayOf: dayOf };
|