@intelligo-dev/executions 1.0.0-beta.1 → 1.0.0-beta.13
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/NOTICE +6 -0
- package/README.md +121 -0
- package/dist/db/schema.d.ts +32 -21
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +23 -23
- package/dist/db/schema.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +31 -5
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +179 -18
- package/dist/lifecycle.js.map +1 -1
- package/dist/ports.d.ts +33 -14
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +3 -8
- package/dist/ports.js.map +1 -1
- package/dist/pricing.d.ts +107 -137
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +129 -85
- package/dist/pricing.js.map +1 -1
- package/dist/queries.d.ts +39 -26
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +96 -39
- package/dist/queries.js.map +1 -1
- package/package.json +41 -13
- package/src/db/schema.ts +87 -0
- package/src/index.ts +73 -0
- package/src/lifecycle.ts +525 -0
- package/src/ports.ts +100 -0
- package/src/pricing.ts +322 -0
- package/src/queries.ts +235 -0
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model registry and execution cost accounting.
|
|
3
|
+
*
|
|
4
|
+
* A leaf: no database, no provider SDK. Its one import is
|
|
5
|
+
* `@intelligo-dev/core/registry`, which is itself dependency-free, so
|
|
6
|
+
* `@intelligo-dev/executions/pricing` is something a client bundle can
|
|
7
|
+
* read a display name or a price from without pulling in Drizzle.
|
|
8
|
+
*
|
|
9
|
+
* The registry is open: a product registers any model it runs, and
|
|
10
|
+
* `registerModels(DEFAULT_MODELS)` from the composition root registers
|
|
11
|
+
* the shipped catalogue. Nothing self-registers. An id with no price
|
|
12
|
+
* throws at the point where the price is needed, naming the id, rather
|
|
13
|
+
* than billing at a guessed rate.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
convert,
|
|
18
|
+
currency,
|
|
19
|
+
money,
|
|
20
|
+
type CurrencyCode,
|
|
21
|
+
type Money,
|
|
22
|
+
} from "@intelligo-dev/core/money";
|
|
23
|
+
import { createRegistry } from "@intelligo-dev/core/registry";
|
|
24
|
+
|
|
25
|
+
export type ModelCapabilities = {
|
|
26
|
+
thinking: boolean;
|
|
27
|
+
toolCall: boolean;
|
|
28
|
+
vision: boolean;
|
|
29
|
+
webSearch: boolean;
|
|
30
|
+
codeExec: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ModelPricing = {
|
|
34
|
+
/** Provider-prefixed id, e.g. `google/gemini-2.5-flash`. */
|
|
35
|
+
id: string;
|
|
36
|
+
provider: string;
|
|
37
|
+
/** The id the provider's own SDK expects, which is often dated. */
|
|
38
|
+
model: string;
|
|
39
|
+
displayName: string;
|
|
40
|
+
/** USD per million input tokens, as the provider quotes it. */
|
|
41
|
+
costPerMInputTokens: number;
|
|
42
|
+
/** USD per million output tokens. */
|
|
43
|
+
costPerMOutputTokens: number;
|
|
44
|
+
/**
|
|
45
|
+
* The ceiling on streamed output used for worst-case pre-request
|
|
46
|
+
* estimation.
|
|
47
|
+
*/
|
|
48
|
+
maxOutputTokens: number;
|
|
49
|
+
capabilities: ModelCapabilities;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A provider-prefixed model id; any string, because the registry is open. */
|
|
53
|
+
export type ModelId = string;
|
|
54
|
+
|
|
55
|
+
export class UnknownModelError extends Error {
|
|
56
|
+
readonly code = "unknown_model";
|
|
57
|
+
readonly modelId: string;
|
|
58
|
+
constructor(modelId: string, registered: readonly string[]) {
|
|
59
|
+
super(
|
|
60
|
+
`No price registered for model "${modelId}". ` +
|
|
61
|
+
`Registered: ${registered.length > 0 ? registered.join(", ") : "none"}. ` +
|
|
62
|
+
`Call registerModels(DEFAULT_MODELS) — or your own catalogue — from the composition root.`
|
|
63
|
+
);
|
|
64
|
+
this.name = "UnknownModelError";
|
|
65
|
+
this.modelId = modelId;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const models = createRegistry<ModelPricing>("executions/model-pricing");
|
|
70
|
+
|
|
71
|
+
/** Register (or replace) one model's price and capabilities. */
|
|
72
|
+
export function registerModel(pricing: ModelPricing): void {
|
|
73
|
+
models.set(pricing.id, pricing);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerModels(pricings: readonly ModelPricing[]): void {
|
|
77
|
+
for (const pricing of pricings) registerModel(pricing);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getModelPricing(modelId: string): ModelPricing | undefined {
|
|
81
|
+
return models.get(modelId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isModelRegistered(modelId: string): boolean {
|
|
85
|
+
return models.has(modelId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listModels(): readonly ModelPricing[] {
|
|
89
|
+
return [...models.values()];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function registeredModelIds(): readonly string[] {
|
|
93
|
+
return [...models.keys()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** For tests composing a fresh root. */
|
|
97
|
+
export function clearModels(): void {
|
|
98
|
+
models.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The catalogue the framework ships, as data.
|
|
103
|
+
*
|
|
104
|
+
* Prices are the providers' published USD rates and go stale; a
|
|
105
|
+
* deployment that cares about the third decimal should register its own
|
|
106
|
+
* contracted rates over these.
|
|
107
|
+
*/
|
|
108
|
+
export const DEFAULT_MODELS: readonly ModelPricing[] = [
|
|
109
|
+
{
|
|
110
|
+
id: "google/gemini-2.5-flash",
|
|
111
|
+
provider: "google",
|
|
112
|
+
model: "gemini-2.5-flash",
|
|
113
|
+
displayName: "Gemini 2.5 Flash",
|
|
114
|
+
costPerMInputTokens: 0.3,
|
|
115
|
+
costPerMOutputTokens: 2.5,
|
|
116
|
+
maxOutputTokens: 8_000,
|
|
117
|
+
capabilities: {
|
|
118
|
+
thinking: true,
|
|
119
|
+
toolCall: true,
|
|
120
|
+
vision: true,
|
|
121
|
+
webSearch: true,
|
|
122
|
+
codeExec: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "google/gemini-2.5-pro",
|
|
127
|
+
provider: "google",
|
|
128
|
+
model: "gemini-2.5-pro",
|
|
129
|
+
displayName: "Gemini 2.5 Pro",
|
|
130
|
+
costPerMInputTokens: 1.25,
|
|
131
|
+
costPerMOutputTokens: 10,
|
|
132
|
+
maxOutputTokens: 8_000,
|
|
133
|
+
capabilities: {
|
|
134
|
+
thinking: true,
|
|
135
|
+
toolCall: true,
|
|
136
|
+
vision: true,
|
|
137
|
+
webSearch: true,
|
|
138
|
+
codeExec: true,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "openai/gpt-5-mini",
|
|
143
|
+
provider: "openai",
|
|
144
|
+
model: "gpt-5-mini",
|
|
145
|
+
displayName: "GPT-5 Mini",
|
|
146
|
+
costPerMInputTokens: 0.25,
|
|
147
|
+
costPerMOutputTokens: 2.0,
|
|
148
|
+
maxOutputTokens: 8_000,
|
|
149
|
+
capabilities: {
|
|
150
|
+
thinking: true,
|
|
151
|
+
toolCall: true,
|
|
152
|
+
vision: true,
|
|
153
|
+
webSearch: false,
|
|
154
|
+
codeExec: false,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "openai/gpt-5.4-mini",
|
|
159
|
+
provider: "openai",
|
|
160
|
+
model: "gpt-5.4-mini",
|
|
161
|
+
displayName: "GPT-5.4 Mini",
|
|
162
|
+
costPerMInputTokens: 0.75,
|
|
163
|
+
costPerMOutputTokens: 4.5,
|
|
164
|
+
maxOutputTokens: 8_000,
|
|
165
|
+
capabilities: {
|
|
166
|
+
thinking: true,
|
|
167
|
+
toolCall: true,
|
|
168
|
+
vision: true,
|
|
169
|
+
webSearch: true,
|
|
170
|
+
codeExec: true,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "openai/o4-mini",
|
|
175
|
+
provider: "openai",
|
|
176
|
+
model: "o4-mini",
|
|
177
|
+
displayName: "o4-mini",
|
|
178
|
+
costPerMInputTokens: 1.1,
|
|
179
|
+
costPerMOutputTokens: 4.4,
|
|
180
|
+
maxOutputTokens: 8_000,
|
|
181
|
+
capabilities: {
|
|
182
|
+
thinking: true,
|
|
183
|
+
toolCall: true,
|
|
184
|
+
vision: true,
|
|
185
|
+
webSearch: true,
|
|
186
|
+
codeExec: true,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "anthropic/claude-sonnet-4-6",
|
|
191
|
+
provider: "anthropic",
|
|
192
|
+
model: "claude-sonnet-4-6-20260214",
|
|
193
|
+
displayName: "Claude Sonnet 4.6",
|
|
194
|
+
costPerMInputTokens: 3.0,
|
|
195
|
+
costPerMOutputTokens: 15.0,
|
|
196
|
+
maxOutputTokens: 8_000,
|
|
197
|
+
capabilities: {
|
|
198
|
+
thinking: true,
|
|
199
|
+
toolCall: true,
|
|
200
|
+
vision: true,
|
|
201
|
+
webSearch: true,
|
|
202
|
+
codeExec: true,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
/** The price of a model, or a loud failure naming the id. */
|
|
208
|
+
function requireModel(modelId: string): ModelPricing {
|
|
209
|
+
const pricing = models.get(modelId);
|
|
210
|
+
if (!pricing) throw new UnknownModelError(modelId, registeredModelIds());
|
|
211
|
+
return pricing;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Providers quote in USD; every cost in this module starts there. */
|
|
215
|
+
export const PROVIDER_CURRENCY: CurrencyCode = currency("USD");
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What the provider charges for this call, in USD.
|
|
219
|
+
*
|
|
220
|
+
* A price quoted per million tokens is, per token, that many millionths
|
|
221
|
+
* of a dollar — so the micros are `tokens × price`. The price is scaled
|
|
222
|
+
* to an integer first, because `100 × 1.1` in floating point is
|
|
223
|
+
* `110.00000000000001` and would round up to a micro nobody used.
|
|
224
|
+
* Fractions of a micro round up, which keeps the cheapest models from
|
|
225
|
+
* pricing a real call at nothing.
|
|
226
|
+
*
|
|
227
|
+
* @throws {UnknownModelError} when the id has no registered price.
|
|
228
|
+
*/
|
|
229
|
+
export function providerCost(
|
|
230
|
+
modelId: string,
|
|
231
|
+
inputTokens: number,
|
|
232
|
+
outputTokens: number
|
|
233
|
+
): Money {
|
|
234
|
+
const config = requireModel(modelId);
|
|
235
|
+
const scaled = (price: number) => Math.round(price * PRICE_SCALE);
|
|
236
|
+
return money(
|
|
237
|
+
Math.ceil(
|
|
238
|
+
(inputTokens * scaled(config.costPerMInputTokens) +
|
|
239
|
+
outputTokens * scaled(config.costPerMOutputTokens)) /
|
|
240
|
+
PRICE_SCALE
|
|
241
|
+
),
|
|
242
|
+
PROVIDER_CURRENCY
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Prices carry at most six decimals of a dollar per million tokens. */
|
|
247
|
+
const PRICE_SCALE = 1_000_000;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a deployment bills in: its currency, what one USD costs in it
|
|
251
|
+
* (micros, so `1_000_000` is a USD deployment and `920_000` a euro one
|
|
252
|
+
* at 0.92), and the margin over provider cost in basis points of a
|
|
253
|
+
* multiplier — `40_000` is 4×.
|
|
254
|
+
*
|
|
255
|
+
* Read per request from `billing_settings`; there is no ambient rate,
|
|
256
|
+
* because a framework that guesses an exchange rate is inventing money.
|
|
257
|
+
*/
|
|
258
|
+
export type BillingRate = {
|
|
259
|
+
currency: CurrencyCode;
|
|
260
|
+
usdRateMicros: number;
|
|
261
|
+
marginBp: number;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `40_000` bp = 4× provider cost, the fallback margin when a
|
|
266
|
+
* deployment's `billing_settings` row names none.
|
|
267
|
+
*
|
|
268
|
+
* INVARIANT — a 65% gross margin floor:
|
|
269
|
+
* gross_margin = 1 − 1/multiplier
|
|
270
|
+
* Keeping gross_margin ≥ 0.65 means a multiplier ≥ 1/0.35 ≈ 2.857. At
|
|
271
|
+
* 4× this yields 75%, leaving buffer above the floor for FX moves and
|
|
272
|
+
* provider price rises. `pricing.test.ts` pins it.
|
|
273
|
+
*/
|
|
274
|
+
export const DEFAULT_MARGIN_BP = 40_000;
|
|
275
|
+
|
|
276
|
+
const BP_PER_MULTIPLE = 10_000;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Provider cost and what the reader is charged for it: cost × margin,
|
|
280
|
+
* converted into the deployment's currency at its own rate.
|
|
281
|
+
*
|
|
282
|
+
* Both steps round up, so a charge is at most two micros over — a
|
|
283
|
+
* millionth of a cent, against a fraction that would otherwise be the
|
|
284
|
+
* deployment's to eat on every request.
|
|
285
|
+
*
|
|
286
|
+
* @throws {UnknownModelError} when the id has no registered price.
|
|
287
|
+
* @throws {MoneyError} when a USD deployment passes a rate that is not 1.
|
|
288
|
+
*/
|
|
289
|
+
export function chargeFor(
|
|
290
|
+
modelId: string,
|
|
291
|
+
inputTokens: number,
|
|
292
|
+
outputTokens: number,
|
|
293
|
+
rate: BillingRate
|
|
294
|
+
): { providerCost: Money; charged: Money } {
|
|
295
|
+
const cost = providerCost(modelId, inputTokens, outputTokens);
|
|
296
|
+
const withMargin = money(
|
|
297
|
+
Math.ceil((cost.amount * rate.marginBp) / BP_PER_MULTIPLE),
|
|
298
|
+
PROVIDER_CURRENCY
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
providerCost: cost,
|
|
302
|
+
charged: convert(withMargin, rate.currency, rate.usdRateMicros),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The ceiling one turn on this model could charge, for admission:
|
|
308
|
+
* the model's own output limit against a 16K input budget (system
|
|
309
|
+
* prompt plus history). Refusing on the ceiling is what stops a turn
|
|
310
|
+
* that cannot be paid for from burning provider tokens first.
|
|
311
|
+
*
|
|
312
|
+
* @throws {UnknownModelError} when the id has no registered price.
|
|
313
|
+
*/
|
|
314
|
+
export function estimateWorstCaseCharge(
|
|
315
|
+
modelId: string,
|
|
316
|
+
rate: BillingRate
|
|
317
|
+
): Money {
|
|
318
|
+
const outputBudget = requireModel(modelId).maxOutputTokens;
|
|
319
|
+
return chargeFor(modelId, ESTIMATE_INPUT_BUDGET, outputBudget, rate).charged;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const ESTIMATE_INPUT_BUDGET = 16_000;
|
package/src/queries.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read models over the executions table, for the product's usage UI
|
|
3
|
+
* and the admin console.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { db } from "@intelligo-dev/core/db";
|
|
7
|
+
import { money, type Money } from "@intelligo-dev/core/money";
|
|
8
|
+
import { and, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm";
|
|
9
|
+
|
|
10
|
+
import { executions } from "./db/schema";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Charges summed per currency.
|
|
14
|
+
*
|
|
15
|
+
* A workspace bills in one currency, so this is normally one entry —
|
|
16
|
+
* but the rows are what the ledger holds, and adding two currencies
|
|
17
|
+
* into a single number is how a total starts lying. Rows with no
|
|
18
|
+
* currency are executions that were never charged.
|
|
19
|
+
*/
|
|
20
|
+
function chargedByCurrency(
|
|
21
|
+
rows: ReadonlyArray<{ currency: string | null; chargedMicros: unknown }>
|
|
22
|
+
): Money[] {
|
|
23
|
+
const totals = new Map<string, number>();
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
if (!row.currency) continue;
|
|
26
|
+
// `sum()` of a bigint column arrives as a string.
|
|
27
|
+
const amount = Number(row.chargedMicros ?? 0);
|
|
28
|
+
if (amount === 0) continue;
|
|
29
|
+
totals.set(row.currency, (totals.get(row.currency) ?? 0) + amount);
|
|
30
|
+
}
|
|
31
|
+
return [...totals].map(([code, amount]) => money(amount, code));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ListExecutionsOptions = {
|
|
35
|
+
/** Required: every query is scoped to one tenant. */
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
userId?: string;
|
|
38
|
+
capability?: string;
|
|
39
|
+
status?: "running" | "settling" | "succeeded" | "failed" | "refused";
|
|
40
|
+
/** Keyset pagination: rows started strictly before this. */
|
|
41
|
+
before?: Date;
|
|
42
|
+
limit?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function listExecutions(options: ListExecutionsOptions) {
|
|
46
|
+
const filters = [
|
|
47
|
+
eq(executions.workspaceId, options.workspaceId),
|
|
48
|
+
options.userId ? eq(executions.userId, options.userId) : undefined,
|
|
49
|
+
options.capability
|
|
50
|
+
? eq(executions.capability, options.capability)
|
|
51
|
+
: undefined,
|
|
52
|
+
options.status ? eq(executions.status, options.status) : undefined,
|
|
53
|
+
options.before ? lt(executions.startedAt, options.before) : undefined,
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
|
|
56
|
+
return db
|
|
57
|
+
.select()
|
|
58
|
+
.from(executions)
|
|
59
|
+
.where(filters.length > 0 ? and(...filters) : undefined)
|
|
60
|
+
.orderBy(desc(executions.startedAt))
|
|
61
|
+
.limit(Math.min(options.limit ?? 50, 500));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getExecutionByRequestId(requestId: string) {
|
|
65
|
+
const [row] = await db
|
|
66
|
+
.select()
|
|
67
|
+
.from(executions)
|
|
68
|
+
.where(eq(executions.requestId, requestId))
|
|
69
|
+
.limit(1);
|
|
70
|
+
return row ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Aggregate counts, tokens, and charge for a workspace over a window.
|
|
75
|
+
* Refused executions are counted separately — they consumed no tokens
|
|
76
|
+
* but they are the signal that a plan's limits are biting.
|
|
77
|
+
*/
|
|
78
|
+
export async function summarizeExecutions(
|
|
79
|
+
workspaceId: string,
|
|
80
|
+
window: { from: Date; to: Date }
|
|
81
|
+
) {
|
|
82
|
+
const rows = await db
|
|
83
|
+
.select({
|
|
84
|
+
status: executions.status,
|
|
85
|
+
currency: executions.currency,
|
|
86
|
+
count: sql<number>`count(*)`,
|
|
87
|
+
totalTokens: sql<number>`coalesce(sum(${executions.totalTokens}), 0)`,
|
|
88
|
+
chargedMicros: sql<string>`coalesce(sum(${executions.chargedMicros}), 0)`,
|
|
89
|
+
})
|
|
90
|
+
.from(executions)
|
|
91
|
+
.where(
|
|
92
|
+
and(
|
|
93
|
+
eq(executions.workspaceId, workspaceId),
|
|
94
|
+
gte(executions.startedAt, window.from),
|
|
95
|
+
lte(executions.startedAt, window.to)
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
.groupBy(executions.status, executions.currency);
|
|
99
|
+
|
|
100
|
+
// A status can arrive as several rows — one per currency — so the
|
|
101
|
+
// per-status view folds them back together.
|
|
102
|
+
const byStatus: Record<
|
|
103
|
+
string,
|
|
104
|
+
{ count: number; totalTokens: number; charged: Money[] }
|
|
105
|
+
> = {};
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
const seen = byStatus[r.status] ?? {
|
|
108
|
+
count: 0,
|
|
109
|
+
totalTokens: 0,
|
|
110
|
+
charged: [],
|
|
111
|
+
};
|
|
112
|
+
byStatus[r.status] = {
|
|
113
|
+
count: seen.count + Number(r.count),
|
|
114
|
+
totalTokens: seen.totalTokens + Number(r.totalTokens),
|
|
115
|
+
charged: chargedByCurrency(rows.filter((row) => row.status === r.status)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
byStatus,
|
|
121
|
+
totals: {
|
|
122
|
+
...rows.reduce(
|
|
123
|
+
(acc, r) => ({
|
|
124
|
+
count: acc.count + Number(r.count),
|
|
125
|
+
totalTokens: acc.totalTokens + Number(r.totalTokens),
|
|
126
|
+
}),
|
|
127
|
+
{ count: 0, totalTokens: 0 }
|
|
128
|
+
),
|
|
129
|
+
charged: chargedByCurrency(rows),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Per-day totals for a workspace over a window — the read model behind
|
|
136
|
+
* a usage chart.
|
|
137
|
+
*
|
|
138
|
+
* Aggregated in SQL rather than by bucketing rows in the application:
|
|
139
|
+
* a month of a busy workspace is thousands of rows to ship over the
|
|
140
|
+
* wire to produce thirty numbers, and any consumer that had to do that
|
|
141
|
+
* bucketing itself would be writing the query this package should own.
|
|
142
|
+
*
|
|
143
|
+
* Days with no activity are absent rather than zero — the caller knows
|
|
144
|
+
* the window it asked for, and a gap means "nothing ran", which a
|
|
145
|
+
* chart should draw as zero and a table should leave empty.
|
|
146
|
+
*
|
|
147
|
+
* `date` is a `YYYY-MM-DD` string in `timeZone`, which defaults to UTC.
|
|
148
|
+
* Pass the reader's own zone: bucketing a +08:00 reader's 00:36 turn in
|
|
149
|
+
* UTC files it on the previous day.
|
|
150
|
+
*/
|
|
151
|
+
export async function summarizeExecutionsByDay(
|
|
152
|
+
workspaceId: string,
|
|
153
|
+
window: { from: Date; to: Date },
|
|
154
|
+
options: { timeZone?: string } = {}
|
|
155
|
+
): Promise<
|
|
156
|
+
Array<{
|
|
157
|
+
date: string;
|
|
158
|
+
count: number;
|
|
159
|
+
totalTokens: number;
|
|
160
|
+
charged: Money[];
|
|
161
|
+
}>
|
|
162
|
+
> {
|
|
163
|
+
// Bound, not interpolated: the zone reaches here from a cookie the
|
|
164
|
+
// browser wrote, so it is caller input like any other.
|
|
165
|
+
//
|
|
166
|
+
// Two conversions, both needed. `started_at` is a naive timestamp
|
|
167
|
+
// holding a UTC instant, so `AT TIME ZONE 'UTC'` turns it into an
|
|
168
|
+
// instant, and the second `AT TIME ZONE` reads that instant as wall
|
|
169
|
+
// time where the reader is. One conversion would instead *declare*
|
|
170
|
+
// the stored time to be the reader's, which is a different moment.
|
|
171
|
+
const timeZone = options.timeZone ?? "UTC";
|
|
172
|
+
const day = sql<string>`to_char(${executions.startedAt} AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone}, 'YYYY-MM-DD')`;
|
|
173
|
+
|
|
174
|
+
const rows = await db
|
|
175
|
+
.select({
|
|
176
|
+
date: day,
|
|
177
|
+
currency: executions.currency,
|
|
178
|
+
count: sql<number>`count(*)`,
|
|
179
|
+
totalTokens: sql<number>`coalesce(sum(${executions.totalTokens}), 0)`,
|
|
180
|
+
chargedMicros: sql<string>`coalesce(sum(${executions.chargedMicros}), 0)`,
|
|
181
|
+
})
|
|
182
|
+
.from(executions)
|
|
183
|
+
.where(
|
|
184
|
+
and(
|
|
185
|
+
eq(executions.workspaceId, workspaceId),
|
|
186
|
+
gte(executions.startedAt, window.from),
|
|
187
|
+
lte(executions.startedAt, window.to)
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
// By ordinal, not by the expression: drizzle inlines the fragment
|
|
191
|
+
// again for each clause, and with the zone bound as a parameter
|
|
192
|
+
// that makes three *different* expressions — Postgres then refuses
|
|
193
|
+
// the grouping. A literal zone matched textually and hid this.
|
|
194
|
+
.groupBy(sql`1`, executions.currency)
|
|
195
|
+
.orderBy(sql`1`);
|
|
196
|
+
|
|
197
|
+
// One row per day and currency; the series a chart draws is per day.
|
|
198
|
+
const byDay = new Map<string, (typeof rows)[number][]>();
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
byDay.set(row.date, [...(byDay.get(row.date) ?? []), row]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return [...byDay].map(([date, dayRows]) => ({
|
|
204
|
+
date,
|
|
205
|
+
count: dayRows.reduce((sum, row) => sum + Number(row.count), 0),
|
|
206
|
+
totalTokens: dayRows.reduce((sum, row) => sum + Number(row.totalTokens), 0),
|
|
207
|
+
charged: chargedByCurrency(dayRows),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Executions stuck in a non-terminal state past a cutoff.
|
|
213
|
+
*
|
|
214
|
+
* `running`: the stream died without reaching complete()/fail() —
|
|
215
|
+
* tokens were consumed and nothing was charged. `settling`: the charge
|
|
216
|
+
* was claimed but never confirmed — EITHER `settleUsage` threw and the
|
|
217
|
+
* workspace was not charged, OR the charge committed and the process
|
|
218
|
+
* died before the final `settling → succeeded` flip, in which case the
|
|
219
|
+
* workspace WAS charged. A `usage_records` row (or a `settled`
|
|
220
|
+
* reservation) for the same `requestId` distinguishes the two. This
|
|
221
|
+
* only reports; `createExecutions().reconcile()` repairs a row.
|
|
222
|
+
*/
|
|
223
|
+
export async function findStaleExecutions(olderThan: Date, limit = 100) {
|
|
224
|
+
return db
|
|
225
|
+
.select()
|
|
226
|
+
.from(executions)
|
|
227
|
+
.where(
|
|
228
|
+
and(
|
|
229
|
+
inArray(executions.status, ["running", "settling"]),
|
|
230
|
+
lt(executions.startedAt, olderThan)
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
.orderBy(desc(executions.startedAt))
|
|
234
|
+
.limit(limit);
|
|
235
|
+
}
|