@hachej/boring-agent 0.1.111 → 0.2.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/LICENSE +1 -1
- package/README.md +262 -51
- package/dist/agents.d.ts +75 -0
- package/dist/agents.js +91 -0
- package/dist/approval.d.ts +71 -0
- package/dist/approval.js +126 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/ask-user.d.ts +40 -0
- package/dist/ask-user.js +134 -0
- package/dist/attachment.d.ts +5 -0
- package/dist/attachment.js +4 -0
- package/dist/browser-task.d.ts +32 -0
- package/dist/browser-task.js +119 -0
- package/dist/chat-transport.d.ts +80 -0
- package/dist/chat-transport.js +306 -0
- package/dist/contracts.d.ts +40 -0
- package/dist/contracts.js +1 -0
- package/dist/conversations.d.ts +136 -0
- package/dist/conversations.js +366 -0
- package/dist/definitions.d.ts +37 -0
- package/dist/definitions.js +99 -0
- package/dist/delivery-input.d.ts +35 -0
- package/dist/delivery-input.js +56 -0
- package/dist/delivery.d.ts +33 -0
- package/dist/delivery.js +154 -0
- package/dist/durable-wait.d.ts +23 -0
- package/dist/durable-wait.js +79 -0
- package/dist/file-convert.d.ts +46 -0
- package/dist/file-convert.js +159 -0
- package/dist/file-guard.d.ts +50 -0
- package/dist/file-guard.js +177 -0
- package/dist/file-types.d.ts +17 -0
- package/dist/file-types.js +95 -0
- package/dist/gateway-provider.d.ts +24 -0
- package/dist/gateway-provider.js +42 -0
- package/dist/git.d.ts +34 -0
- package/dist/git.js +79 -0
- package/dist/harness-pool.d.ts +38 -0
- package/dist/harness-pool.js +134 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/json-schema-tool.d.ts +25 -0
- package/dist/json-schema-tool.js +19 -0
- package/dist/memory/optchat-core.d.ts +118 -0
- package/dist/memory/optchat-core.js +317 -0
- package/dist/memory/optchat.d.ts +156 -0
- package/dist/memory/optchat.js +432 -0
- package/dist/mentions.d.ts +56 -0
- package/dist/mentions.js +140 -0
- package/dist/metering.d.ts +183 -0
- package/dist/metering.js +443 -0
- package/dist/model-gateway.d.ts +86 -0
- package/dist/model-gateway.js +241 -0
- package/dist/native-version.d.ts +9 -0
- package/dist/native-version.js +7 -0
- package/dist/native.d.ts +4 -0
- package/dist/native.js +1 -0
- package/dist/presentation.d.ts +17 -0
- package/dist/presentation.js +39 -0
- package/dist/projection-format.d.ts +68 -0
- package/dist/projection-format.js +118 -0
- package/dist/projection-window.d.ts +9 -0
- package/dist/projection-window.js +93 -0
- package/dist/projection.d.ts +22 -0
- package/dist/projection.js +153 -0
- package/dist/question-response.d.ts +20 -0
- package/dist/question-response.js +75 -0
- package/dist/questions.d.ts +74 -0
- package/dist/questions.js +199 -0
- package/dist/self-evolving.d.ts +55 -0
- package/dist/self-evolving.js +239 -0
- package/dist/skills.d.ts +15 -0
- package/dist/skills.js +51 -0
- package/dist/tool-evidence.d.ts +14 -0
- package/dist/tool-evidence.js +54 -0
- package/dist/validation.d.ts +38 -0
- package/dist/validation.js +68 -0
- package/dist/workspaces.d.ts +107 -0
- package/dist/workspaces.js +212 -0
- package/package.json +145 -150
- package/dist/DebugDrawer-JNA43SVR.js +0 -8
- package/dist/admin-DORVQfLn.d.ts +0 -340
- package/dist/chunk-2BGLZZ7T.js +0 -10
- package/dist/chunk-42D6KAQV.js +0 -2882
- package/dist/chunk-4XOW57KJ.js +0 -343
- package/dist/chunk-72KT6VWV.js +0 -1338
- package/dist/chunk-7XAOVFSN.js +0 -28
- package/dist/chunk-KJIEMSQP.js +0 -198
- package/dist/chunk-KN72XWP7.js +0 -394
- package/dist/chunk-N6THTH7P.js +0 -110
- package/dist/chunk-V3H5A3BR.js +0 -22501
- package/dist/chunk-YGRQ4Q5P.js +0 -20
- package/dist/chunk-YTUZFKB3.js +0 -78
- package/dist/core/index.d.ts +0 -28
- package/dist/core/index.js +0 -617
- package/dist/createHarness-DOLOHA62.js +0 -18
- package/dist/eval/index.d.ts +0 -247
- package/dist/eval/index.js +0 -541
- package/dist/file-search-BUgMSKZE.d.ts +0 -289
- package/dist/front/artifacts.d.ts +0 -34
- package/dist/front/artifacts.js +0 -21
- package/dist/front/index.d.ts +0 -1082
- package/dist/front/index.js +0 -12912
- package/dist/front/styles.css +0 -5380
- package/dist/harness-qHlMJhZo.d.ts +0 -276
- package/dist/middleware-CaAEXpi5.d.ts +0 -115
- package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
- package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
- package/dist/sandbox-BMhut3kh.d.ts +0 -317
- package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
- package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
- package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
- package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
- package/dist/server/index.d.ts +0 -2278
- package/dist/server/index.js +0 -274
- package/dist/server/pi-session-readability.d.ts +0 -12
- package/dist/server/pi-session-readability.js +0 -23
- package/dist/server/worker/index.d.ts +0 -68
- package/dist/server/worker/index.js +0 -377
- package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
- package/dist/shared/index.d.ts +0 -2111
- package/dist/shared/index.js +0 -262
- package/dist/types-DcWoQMn8.d.ts +0 -1377
- package/docs/ACCESSIBILITY.md +0 -52
- package/docs/AGENT_GATEWAY_V0.md +0 -212
- package/docs/AGENT_PACKAGES.md +0 -90
- package/docs/API.md +0 -123
- package/docs/AUTHORING.md +0 -68
- package/docs/CSP.md +0 -41
- package/docs/ERROR_CODES.md +0 -229
- package/docs/KNOWN_LIMITATIONS.md +0 -100
- package/docs/MIGRATION.md +0 -50
- package/docs/PERFORMANCE.md +0 -68
- package/docs/PLUGINS.md +0 -138
- package/docs/README.md +0 -98
- package/docs/RISKS-MULTI-TAB.md +0 -46
- package/docs/SESSION_IDENTITY.md +0 -91
- package/docs/STYLING.md +0 -73
- package/docs/UI-SHADCN.md +0 -56
- package/docs/VERCEL_COSTS.md +0 -103
- package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
- package/docs/plans/archive/agent-package-spec.md +0 -1777
- package/docs/plans/archive/harness-followup-capabilities.md +0 -440
- package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
- package/docs/plans/archive/pi-followup-history-projection.md +0 -229
- package/docs/plans/archive/pi-tools-migration.md +0 -1061
- package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
- package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
- package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
- package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
- package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
- package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
- package/docs/runtime-provisioning.md +0 -157
- package/docs/runtime.md +0 -296
- package/docs/tools.md +0 -102
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord';
|
|
2
|
+
import type { Conversation, Harness } from '@earendil-works/pi-durable';
|
|
3
|
+
import type { Models, Usage } from '@earendil-works/pi-ai';
|
|
4
|
+
import { type SqliteConnection, type SqliteSettings } from '@hachej/boring-files/sqlite';
|
|
5
|
+
/** Small flat JSON-safe values the host passes through untouched (a plan, a tenant): at most 16 keys, strings of at most 256 characters. */
|
|
6
|
+
export type MeteringAttributes = Readonly<Record<string, string | number | boolean>>;
|
|
7
|
+
/** Who a run is for, as the host maps its own authentication: the person, optionally the workspace, and pass-through attributes. */
|
|
8
|
+
export interface MeteringScope {
|
|
9
|
+
readonly userId: string;
|
|
10
|
+
readonly workspaceId?: string;
|
|
11
|
+
readonly attributes?: MeteringAttributes;
|
|
12
|
+
}
|
|
13
|
+
/** The host's scope plus which native submission a run is. `runId` is derived from the conversation and the submission's request ID. */
|
|
14
|
+
export interface MeteringRunScope extends MeteringScope {
|
|
15
|
+
readonly conversationId: number;
|
|
16
|
+
readonly requestId: string;
|
|
17
|
+
readonly runId: string;
|
|
18
|
+
}
|
|
19
|
+
/** A model as pi-ai names it (`provider`, model `id`). */
|
|
20
|
+
export interface MeteringModel {
|
|
21
|
+
readonly provider: string;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The native submission kind of the input: `input` starts a run (or queues as one); `followUp` and `steer` are the input's
|
|
26
|
+
* `whenBusy` choice, joining a busy conversation after or into its current run (the kind the client asked for: on an idle
|
|
27
|
+
* conversation Pi starts a run with it all the same).
|
|
28
|
+
*/
|
|
29
|
+
export type MeteringSubmissionKind = 'input' | 'followUp' | 'steer';
|
|
30
|
+
export interface MeteringReserveInput extends MeteringRunScope {
|
|
31
|
+
/** The model the run will use: the conversation's selected model, resolved through Pi (`Conversation.agent`) at submit time. Absent when none is selected. */
|
|
32
|
+
readonly model?: MeteringModel;
|
|
33
|
+
readonly kind: MeteringSubmissionKind;
|
|
34
|
+
/** The input's text, whitespace collapsed and cut to `MESSAGE_PREVIEW_LENGTH` characters (`[image]` for an image part), for logs and policy. */
|
|
35
|
+
readonly message: string;
|
|
36
|
+
/** The input as submitted, for a host that sizes holds by it. */
|
|
37
|
+
readonly content: unknown;
|
|
38
|
+
}
|
|
39
|
+
export type MeteringReservation = {
|
|
40
|
+
readonly reservationId?: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'refused';
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
};
|
|
45
|
+
export interface MeteringUsageInput extends MeteringRunScope {
|
|
46
|
+
readonly reservationId?: string;
|
|
47
|
+
/** Stable idempotency key: the native entry that carries the usage (`entry:<conversation>:<entry>`). */
|
|
48
|
+
readonly usageId: string;
|
|
49
|
+
/** The model of this usage report (a run can switch models mid-run); absent for a tool's own usage. */
|
|
50
|
+
readonly model?: MeteringModel;
|
|
51
|
+
/** `model:<provider>/<id>` or `tool:<name>`, as Pi keys `pi.usage`. */
|
|
52
|
+
readonly bucket: string;
|
|
53
|
+
readonly usage: Usage;
|
|
54
|
+
/** The host's price for this usage, markup included, in the ledger's unit (micros). */
|
|
55
|
+
readonly amountMicros: number;
|
|
56
|
+
}
|
|
57
|
+
export interface MeteringSettleInput extends MeteringRunScope {
|
|
58
|
+
readonly reservationId?: string;
|
|
59
|
+
readonly status: 'done' | 'unanswered';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `fallback-charge` and `usage-write-failed` charge the hold (the run did work it cannot account for); every other reason frees it.
|
|
63
|
+
* `not-started`: the input never reached the native conversation. `cancelled`: the person stopped or withdrew it. `no-model-call`:
|
|
64
|
+
* a later input took the run over before any model call of its own. `error-before-usage`: it ended in an error without usage.
|
|
65
|
+
*/
|
|
66
|
+
export type MeteringReleaseReason = 'not-started' | 'cancelled' | 'no-model-call' | 'error-before-usage' | 'fallback-charge' | 'usage-write-failed';
|
|
67
|
+
export interface MeteringReleaseInput extends MeteringRunScope {
|
|
68
|
+
readonly reservationId?: string;
|
|
69
|
+
readonly reason: MeteringReleaseReason;
|
|
70
|
+
}
|
|
71
|
+
/** A reservation the ledger holds without a settle or release, for `recover` after a restart. */
|
|
72
|
+
export interface OpenMeteringRun extends MeteringRunScope {
|
|
73
|
+
readonly reservationId?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The host's ledger (v2's `AgentMeteringSink`, renamed). Every run ends with exactly one `settleRun` or `releaseRun` from the meter,
|
|
77
|
+
* but restarts and client retries replay calls, so all four methods are idempotent on `runId` (and `usageId`).
|
|
78
|
+
*/
|
|
79
|
+
export interface MeteringSink {
|
|
80
|
+
/** False: installed but switched off; nothing is reserved or refused. Absent: on. */
|
|
81
|
+
readonly isEnabled?: () => boolean;
|
|
82
|
+
/** Before the input is submitted. A refusal (or a throw: fail closed) keeps it from the conversation. */
|
|
83
|
+
readonly reserveRun: (input: MeteringReserveInput) => Promise<MeteringReservation>;
|
|
84
|
+
/** One native usage report; returns what it actually billed. */
|
|
85
|
+
readonly recordUsage: (input: MeteringUsageInput) => Promise<{
|
|
86
|
+
readonly billedMicros: number;
|
|
87
|
+
}>;
|
|
88
|
+
readonly settleRun: (input: MeteringSettleInput) => Promise<void>;
|
|
89
|
+
readonly releaseRun: (input: MeteringReleaseInput) => Promise<void>;
|
|
90
|
+
/** Runs reserved and not yet settled or released. Needed by `Meter.recover`. */
|
|
91
|
+
readonly openRuns?: () => Promise<readonly OpenMeteringRun[]>;
|
|
92
|
+
}
|
|
93
|
+
/** The host's price of one usage report in micros (before markup). `model` is absent for a tool's own usage. */
|
|
94
|
+
export type MeteringPrice = (usage: Usage, model: MeteringModel | undefined) => number;
|
|
95
|
+
/**
|
|
96
|
+
* Default price: Pi's own `calculateCost` with the model's rates from `models` (USD per million tokens), else the cost the
|
|
97
|
+
* provider reported on the message, as micro-dollars.
|
|
98
|
+
*/
|
|
99
|
+
export declare function catalogPrice(models?: Pick<Models, 'getModel'>): MeteringPrice;
|
|
100
|
+
/** A submission the meter's ledger refused. The chat transport answers it with 402 and this message, and nothing is submitted. */
|
|
101
|
+
export declare class MeteringRefused extends Error {
|
|
102
|
+
readonly code = "submission-refused";
|
|
103
|
+
constructor(message: string);
|
|
104
|
+
}
|
|
105
|
+
export interface MeterOptions {
|
|
106
|
+
readonly sink: MeteringSink;
|
|
107
|
+
readonly context: Context;
|
|
108
|
+
/** Default `catalogPrice(models)`. */
|
|
109
|
+
readonly price?: MeteringPrice;
|
|
110
|
+
readonly models?: Pick<Models, 'getModel'>;
|
|
111
|
+
/** Multiplier applied to every price (default 1). */
|
|
112
|
+
readonly markup?: number;
|
|
113
|
+
/** A sink call failed; the meter retries usage on the next change and charges the hold if it still fails at the end. */
|
|
114
|
+
readonly onError?: (message: string, error: unknown) => void;
|
|
115
|
+
}
|
|
116
|
+
export interface Meter {
|
|
117
|
+
/**
|
|
118
|
+
* A view of `conversation` whose input `submit` reserves a run for `scope` (the host's mapping of its authentication) first: a
|
|
119
|
+
* refusal throws `MeteringRefused` and nothing is submitted. Every other member is the native conversation. A request ID is
|
|
120
|
+
* required: it is the run's identity.
|
|
121
|
+
*/
|
|
122
|
+
readonly conversation: (conversation: Conversation, scope: MeteringScope) => Conversation;
|
|
123
|
+
/** After a restart: finish every run the ledger still holds open, from the conversation's durable state, and watch the rest. */
|
|
124
|
+
readonly recover: (harness: Harness) => Promise<void>;
|
|
125
|
+
/** Resolves when every pending sink call has been made (tests, shutdown). */
|
|
126
|
+
readonly flush: () => Promise<void>;
|
|
127
|
+
/** Stop watching. Open runs stay open in the ledger; `recover` finishes them later. */
|
|
128
|
+
readonly close: () => Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
/** The longest `message` preview a reservation carries. */
|
|
131
|
+
export declare const MESSAGE_PREVIEW_LENGTH = 280;
|
|
132
|
+
/** Meter submissions of native conversations into the host's `sink`. */
|
|
133
|
+
export declare function createMeter(options: MeterOptions): Meter;
|
|
134
|
+
/**
|
|
135
|
+
* A ledger: the sink plus the balance reads and grants a host needs around it. A balance is named by its key (`balanceKey(scope)`,
|
|
136
|
+
* by default the run's `userId`). Amounts are integer micros.
|
|
137
|
+
*/
|
|
138
|
+
export interface MeteringLedger extends MeteringSink {
|
|
139
|
+
readonly openRuns: () => Promise<readonly OpenMeteringRun[]>;
|
|
140
|
+
/** `balanceMicros` is grants minus charges; `heldMicros` the unspent part of open holds; `availableMicros` what a new run can use. */
|
|
141
|
+
readonly balance: (key: string) => Promise<{
|
|
142
|
+
readonly balanceMicros: number;
|
|
143
|
+
readonly heldMicros: number;
|
|
144
|
+
readonly availableMicros: number;
|
|
145
|
+
}>;
|
|
146
|
+
/** Add credits (negative removes them); idempotent on `grantId`. */
|
|
147
|
+
readonly grant: (key: string, amountMicros: number, grantId: string) => Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
export interface LedgerOptions {
|
|
150
|
+
/**
|
|
151
|
+
* What a run holds while it runs, and what a fallback charge tops the run's charges up to. A function sizes it per reservation
|
|
152
|
+
* (model-aware admission: a costly model holds more, so a low balance refuses it while a cheaper one is still admitted).
|
|
153
|
+
*/
|
|
154
|
+
readonly holdMicros: number | ((input: MeteringReserveInput) => number);
|
|
155
|
+
/** The balance a run draws on. Default its `userId`; `scope => scope.workspaceId ?? scope.userId` pools a workspace. */
|
|
156
|
+
readonly balanceKey?: (scope: MeteringScope) => string;
|
|
157
|
+
/** The refusal shown to the person. Default names the available and needed amounts in millionths. */
|
|
158
|
+
readonly refusal?: (availableMicros: number, holdMicros: number, input: MeteringReserveInput) => string;
|
|
159
|
+
}
|
|
160
|
+
/** The ledger in memory, for tests and examples: one process, lost on exit. */
|
|
161
|
+
export declare function createMemoryLedger(options: LedgerOptions): MeteringLedger;
|
|
162
|
+
/** Where a SQLite ledger lives: a borrowed connection, or a file the ledger opens (`openNodeConnection(filename, sqlite)`) and owns. */
|
|
163
|
+
export type SqliteLedgerStore = {
|
|
164
|
+
readonly connection: SqliteConnection;
|
|
165
|
+
readonly filename?: never;
|
|
166
|
+
readonly sqlite?: never;
|
|
167
|
+
} | {
|
|
168
|
+
readonly filename: string;
|
|
169
|
+
readonly sqlite?: SqliteSettings;
|
|
170
|
+
readonly connection?: never;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* The ledger in SQLite: grants, runs with their scope, model, kind and hold, and charges keyed by (run, step) with the usage's model,
|
|
174
|
+
* so a replayed usage report or fallback charge is never charged twice. Over a borrowed `SqliteConnection` (`openNodeConnection`,
|
|
175
|
+
* `durableObjectSqliteConnection`, ...), or a `filename` it opens with `sqlite` settings (`@hachej/boring-files/sqlite`: local disk WAL by
|
|
176
|
+
* default, `sqliteSettings.networkFilesystem` on EFS) and closes on `close()`. Every write is one write transaction (`BEGIN
|
|
177
|
+
* IMMEDIATE` on `node:sqlite`): several processes or requests on one ledger file wait for each other up to the busy timeout instead
|
|
178
|
+
* of failing, and a reservation's balance check and hold commit together, so concurrent reservations never spend the same credit twice.
|
|
179
|
+
*/
|
|
180
|
+
export declare function createSqliteLedger(options: LedgerOptions & SqliteLedgerStore): MeteringLedger & {
|
|
181
|
+
readonly close: () => void;
|
|
182
|
+
};
|
|
183
|
+
//# sourceMappingURL=metering.d.ts.map
|
package/dist/metering.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { AssistantEntry, UserEntry } from '@earendil-works/pi-durable';
|
|
2
|
+
import { calculateCost } from '@earendil-works/pi-ai/models';
|
|
3
|
+
import { openNodeConnection } from '@hachej/boring-files/sqlite';
|
|
4
|
+
/**
|
|
5
|
+
* Default price: Pi's own `calculateCost` with the model's rates from `models` (USD per million tokens), else the cost the
|
|
6
|
+
* provider reported on the message, as micro-dollars.
|
|
7
|
+
*/
|
|
8
|
+
export function catalogPrice(models) {
|
|
9
|
+
return (usage, model) => {
|
|
10
|
+
const known = model ? models?.getModel(model.provider, model.id) : undefined;
|
|
11
|
+
const total = known ? calculateCost(known, structuredClone(usage)).total : usage.cost?.total ?? 0;
|
|
12
|
+
return Number.isFinite(total) && total > 0 ? total * 1_000_000 : 0;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** A submission the meter's ledger refused. The chat transport answers it with 402 and this message, and nothing is submitted. */
|
|
16
|
+
export class MeteringRefused extends Error {
|
|
17
|
+
code = 'submission-refused';
|
|
18
|
+
constructor(message) { super(message); this.name = 'MeteringRefused'; }
|
|
19
|
+
}
|
|
20
|
+
const runIdOf = (conversationId, requestId) => `run:${conversationId}:${requestId}`;
|
|
21
|
+
/** The longest `message` preview a reservation carries. */
|
|
22
|
+
export const MESSAGE_PREVIEW_LENGTH = 280;
|
|
23
|
+
function previewOf(content) {
|
|
24
|
+
const parts = typeof content === 'string' ? [content] : Array.isArray(content)
|
|
25
|
+
? content.map(part => part?.type === 'text' && typeof part.text === 'string' ? part.text : part?.type === 'image' ? '[image]' : '') : [];
|
|
26
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
27
|
+
return text.length > MESSAGE_PREVIEW_LENGTH ? `${text.slice(0, MESSAGE_PREVIEW_LENGTH - 1)}…` : text;
|
|
28
|
+
}
|
|
29
|
+
/** A copy of the host's scope with only its own fields, checked: a non-empty `userId`, an optional non-empty `workspaceId`, small flat attributes. */
|
|
30
|
+
function scopeOf(scope) {
|
|
31
|
+
if (typeof scope?.userId !== 'string' || scope.userId === '')
|
|
32
|
+
throw new TypeError('A metering scope needs a userId');
|
|
33
|
+
if (scope.workspaceId !== undefined && (typeof scope.workspaceId !== 'string' || scope.workspaceId === ''))
|
|
34
|
+
throw new TypeError('workspaceId must be a non-empty string');
|
|
35
|
+
const attributes = scope.attributes;
|
|
36
|
+
if (attributes !== undefined) {
|
|
37
|
+
const entries = attributes !== null && typeof attributes === 'object' && !Array.isArray(attributes) ? Object.entries(attributes) : undefined;
|
|
38
|
+
if (!entries || entries.length > 16 || entries.some(([key, value]) => key.length === 0 || key.length > 64
|
|
39
|
+
|| !(typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.length <= 256)))) {
|
|
40
|
+
throw new TypeError('attributes must be at most 16 keys of strings (256 characters), finite numbers or booleans');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze({ userId: scope.userId, ...(scope.workspaceId === undefined ? {} : { workspaceId: scope.workspaceId }),
|
|
44
|
+
...(scope.attributes === undefined ? {} : { attributes: Object.freeze({ ...scope.attributes }) }) });
|
|
45
|
+
}
|
|
46
|
+
/** The run scope fields of `open` (a ledger row may carry more). */
|
|
47
|
+
const runScopeOf = (open) => ({ ...scopeOf(open), conversationId: open.conversationId, requestId: open.requestId, runId: open.runId });
|
|
48
|
+
const isZero = (usage) => usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0
|
|
49
|
+
&& (usage.totalTokens ?? 0) === 0 && (usage.cost?.total ?? 0) === 0;
|
|
50
|
+
/** Usage-bearing entries of the run whose input placed `userEntry`: from it up to the next placed input (which owns what follows). */
|
|
51
|
+
async function reportsOf(conversation, userEntry, context) {
|
|
52
|
+
const newestFirst = [];
|
|
53
|
+
let cursor;
|
|
54
|
+
do {
|
|
55
|
+
const page = await conversation.entries({ minEntryId: userEntry }, 100, cursor, context);
|
|
56
|
+
newestFirst.push(...page.items);
|
|
57
|
+
cursor = page.next;
|
|
58
|
+
} while (cursor !== undefined);
|
|
59
|
+
const own = [];
|
|
60
|
+
for (const entry of newestFirst.reverse()) {
|
|
61
|
+
if (Number(entry.id) <= userEntry)
|
|
62
|
+
continue;
|
|
63
|
+
if (UserEntry.is(entry))
|
|
64
|
+
break;
|
|
65
|
+
own.push(entry);
|
|
66
|
+
}
|
|
67
|
+
const reports = [];
|
|
68
|
+
for (const entry of own) {
|
|
69
|
+
const message = entry.model?.[0];
|
|
70
|
+
if (!message?.usage)
|
|
71
|
+
continue;
|
|
72
|
+
if (message.role === 'assistant') {
|
|
73
|
+
const model = { provider: String(message.provider), id: String(message.model) };
|
|
74
|
+
reports.push({ entry, usage: message.usage, model, bucket: `model:${model.provider}/${model.id}`, aborted: message.stopReason === 'aborted' });
|
|
75
|
+
}
|
|
76
|
+
else if (message.role === 'toolResult')
|
|
77
|
+
reports.push({ entry, usage: message.usage, bucket: `tool:${message.toolName}`, aborted: false });
|
|
78
|
+
}
|
|
79
|
+
return { reports, answered: own.some(entry => AssistantEntry.is(entry)) };
|
|
80
|
+
}
|
|
81
|
+
/** Meter submissions of native conversations into the host's `sink`. */
|
|
82
|
+
export function createMeter(options) {
|
|
83
|
+
const { sink, context } = options;
|
|
84
|
+
const price = options.price ?? catalogPrice(options.models);
|
|
85
|
+
const markup = options.markup ?? 1;
|
|
86
|
+
if (!(markup > 0))
|
|
87
|
+
throw new TypeError('markup must be a positive number');
|
|
88
|
+
const report = options.onError ?? ((message, error) => { console.error(`metering: ${message}`, error); });
|
|
89
|
+
const enabled = () => sink.isEnabled?.() !== false;
|
|
90
|
+
const watched = new Map();
|
|
91
|
+
let closed = false;
|
|
92
|
+
function watchedOf(conversation) {
|
|
93
|
+
const id = Number(conversation.id);
|
|
94
|
+
let found = watched.get(id);
|
|
95
|
+
if (!found) {
|
|
96
|
+
found = { conversation, runs: new Map(), watch: undefined, chain: Promise.resolve(), again: false, syncing: false };
|
|
97
|
+
watched.set(id, found);
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
/** Re-read the durable state of `state`'s open runs; coalesced and serialized per conversation. */
|
|
102
|
+
function schedule(state) {
|
|
103
|
+
if (state.syncing) {
|
|
104
|
+
state.again = true;
|
|
105
|
+
return state.chain;
|
|
106
|
+
}
|
|
107
|
+
state.syncing = true;
|
|
108
|
+
state.chain = state.chain.then(async () => {
|
|
109
|
+
do {
|
|
110
|
+
state.again = false;
|
|
111
|
+
await sync(state).catch(error => report('sync failed', error));
|
|
112
|
+
} while (state.again && !closed);
|
|
113
|
+
state.syncing = false;
|
|
114
|
+
if (state.runs.size === 0 && state.watch) {
|
|
115
|
+
const watch = state.watch;
|
|
116
|
+
state.watch = undefined;
|
|
117
|
+
await watch.stop();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return state.chain;
|
|
121
|
+
}
|
|
122
|
+
async function observe(state) {
|
|
123
|
+
if (state.watch || closed)
|
|
124
|
+
return;
|
|
125
|
+
const watch = await state.conversation.watch(context);
|
|
126
|
+
if (state.watch || closed) {
|
|
127
|
+
await watch.stop();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
state.watch = watch;
|
|
131
|
+
// A streamed partial changes only `pi.live.generation`: re-reading the runs then would only add reads to the session line.
|
|
132
|
+
let last;
|
|
133
|
+
watch.start(async (view) => {
|
|
134
|
+
const { generation: _partial, ...live } = (view.docs['pi.live'] ?? {});
|
|
135
|
+
const key = JSON.stringify([view.entries.at(-1)?.id, view.entries.length, view.docs['pi.inbox'], live]);
|
|
136
|
+
if (key === last)
|
|
137
|
+
return;
|
|
138
|
+
last = key;
|
|
139
|
+
void schedule(state);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async function sync(state) {
|
|
143
|
+
for (const run of [...state.runs.values()]) {
|
|
144
|
+
if (run.submitting)
|
|
145
|
+
continue;
|
|
146
|
+
const record = await state.conversation.commit(tx => tx.submissionByRequest(state.conversation.id, run.scope.requestId), context);
|
|
147
|
+
if (!record) {
|
|
148
|
+
await finish(state, run, { release: 'not-started' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const { reports, answered } = record.entry === undefined ? { reports: [], answered: false } : await reportsOf(state.conversation, Number(record.entry), context);
|
|
152
|
+
for (const item of reports) {
|
|
153
|
+
if (run.recorded.has(Number(item.entry.id)))
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
const amountMicros = Math.ceil(Math.max(0, price(item.usage, item.model)) * markup);
|
|
157
|
+
const { billedMicros } = await sink.recordUsage({ ...run.scope, ...(run.reservationId ? { reservationId: run.reservationId } : {}),
|
|
158
|
+
usageId: `entry:${run.scope.conversationId}:${item.entry.id}`, ...(item.model ? { model: item.model } : {}), bucket: item.bucket, usage: item.usage, amountMicros });
|
|
159
|
+
run.recorded.set(Number(item.entry.id), billedMicros);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
report(`recordUsage failed (${run.scope.runId}); retried on the next change`, error);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (record.status === 'done' || record.status === 'unanswered')
|
|
166
|
+
await finish(state, run, decide(run, record, reports, answered));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The termination rule (v2's, on native state). In order: a usage report the sink still has not accepted charges the hold; a
|
|
171
|
+
* person's stop settles what was billed or frees the hold; an attempt interrupted with unknown usage (an aborted partial without
|
|
172
|
+
* billed usage), or a model call whose usage priced to nothing without being an explicit zero report, charges the hold; billed
|
|
173
|
+
* usage settles; explicit zero reports settle at zero; an answer without any usage report charges the hold; no model call at all
|
|
174
|
+
* frees it.
|
|
175
|
+
*/
|
|
176
|
+
function decide(run, record, reports, answered) {
|
|
177
|
+
const status = record.status === 'done' ? 'done' : 'unanswered';
|
|
178
|
+
const billed = reports.some(item => (run.recorded.get(Number(item.entry.id)) ?? 0) > 0);
|
|
179
|
+
if (reports.some(item => !run.recorded.has(Number(item.entry.id))))
|
|
180
|
+
return { release: 'usage-write-failed' };
|
|
181
|
+
if (record.status === 'unanswered' && record.reason === 'aborted')
|
|
182
|
+
return billed ? { settle: status } : { release: record.entry === undefined ? 'not-started' : 'cancelled' };
|
|
183
|
+
if (record.status === 'unanswered' && record.entry === undefined)
|
|
184
|
+
return { release: 'not-started' };
|
|
185
|
+
const unknown = reports.some(item => (run.recorded.get(Number(item.entry.id)) ?? 0) === 0 && (item.aborted || !isZero(item.usage)));
|
|
186
|
+
if (unknown)
|
|
187
|
+
return { release: 'fallback-charge' };
|
|
188
|
+
if (billed || reports.length > 0)
|
|
189
|
+
return { settle: status };
|
|
190
|
+
if (answered)
|
|
191
|
+
return { release: 'fallback-charge' };
|
|
192
|
+
return { release: record.status === 'done' ? 'no-model-call' : 'error-before-usage' };
|
|
193
|
+
}
|
|
194
|
+
async function finish(state, run, decision) {
|
|
195
|
+
const base = { ...run.scope, ...(run.reservationId ? { reservationId: run.reservationId } : {}) };
|
|
196
|
+
try {
|
|
197
|
+
if ('settle' in decision)
|
|
198
|
+
await sink.settleRun({ ...base, status: decision.settle });
|
|
199
|
+
else
|
|
200
|
+
await sink.releaseRun({ ...base, reason: decision.release });
|
|
201
|
+
state.runs.delete(run.scope.runId);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
report(`terminating ${run.scope.runId} failed; retried on the next change or recover()`, error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function track(state, scope, reservationId, submitting) {
|
|
208
|
+
const run = { scope, ...(reservationId ? { reservationId } : {}), submitting, recorded: new Map() };
|
|
209
|
+
state.runs.set(scope.runId, run);
|
|
210
|
+
return run;
|
|
211
|
+
}
|
|
212
|
+
return Object.freeze({
|
|
213
|
+
conversation: (conversation, hostScope) => {
|
|
214
|
+
const owner = scopeOf(hostScope);
|
|
215
|
+
return new Proxy(conversation, { get: (target, key) => {
|
|
216
|
+
if (key === 'submit') {
|
|
217
|
+
return async (draft, submitContext) => {
|
|
218
|
+
if (draft.type !== 'input' || !enabled())
|
|
219
|
+
return target.submit(draft, submitContext);
|
|
220
|
+
if (!draft.requestId)
|
|
221
|
+
throw new TypeError('A metered submission needs a request ID');
|
|
222
|
+
const conversationId = Number(target.id);
|
|
223
|
+
const scope = { ...owner, conversationId, requestId: draft.requestId, runId: runIdOf(conversationId, draft.requestId) };
|
|
224
|
+
const state = watchedOf(target);
|
|
225
|
+
// The model this run will use: the conversation's selected model as Pi resolves it now.
|
|
226
|
+
const selected = (await target.agent(submitContext)).model;
|
|
227
|
+
const reservation = await sink.reserveRun({ ...scope, ...(selected ? { model: { provider: selected.provider, id: selected.modelId } } : {}),
|
|
228
|
+
kind: draft.whenBusy === 'steer' || draft.whenBusy === 'followUp' ? draft.whenBusy : 'input', message: previewOf(draft.content), content: draft.content });
|
|
229
|
+
if ('kind' in reservation && reservation.kind === 'refused')
|
|
230
|
+
throw new MeteringRefused(reservation.reason);
|
|
231
|
+
const reservationId = 'reservationId' in reservation ? reservation.reservationId : undefined;
|
|
232
|
+
const run = state.runs.get(scope.runId) ?? track(state, scope, reservationId, true);
|
|
233
|
+
// Not admitted (busy with whenBusy "reject", storage refusal): no submission record, so the sync below frees the hold.
|
|
234
|
+
try {
|
|
235
|
+
await observe(state);
|
|
236
|
+
return await target.submit(draft, submitContext);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
run.submitting = false;
|
|
240
|
+
void schedule(state);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const value = Reflect.get(target, key, target);
|
|
245
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
246
|
+
} });
|
|
247
|
+
},
|
|
248
|
+
recover: async (harness) => {
|
|
249
|
+
if (!sink.openRuns)
|
|
250
|
+
throw new TypeError('recover needs a sink with openRuns()');
|
|
251
|
+
for (const open of await sink.openRuns()) {
|
|
252
|
+
const conversation = await harness.conversation(open.conversationId, context);
|
|
253
|
+
const scope = runScopeOf(open);
|
|
254
|
+
const base = { ...scope, ...(open.reservationId ? { reservationId: open.reservationId } : {}) };
|
|
255
|
+
if (!conversation) {
|
|
256
|
+
await sink.releaseRun({ ...base, reason: 'not-started' });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const state = watchedOf(conversation);
|
|
260
|
+
if (!state.runs.has(open.runId))
|
|
261
|
+
track(state, scope, open.reservationId, false);
|
|
262
|
+
}
|
|
263
|
+
for (const state of watched.values()) {
|
|
264
|
+
await observe(state);
|
|
265
|
+
await schedule(state);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
flush: async () => {
|
|
269
|
+
for (;;) {
|
|
270
|
+
const pending = [...watched.values()].map(state => state.chain);
|
|
271
|
+
await Promise.all(pending);
|
|
272
|
+
if ([...watched.values()].every((state, at) => state.chain === pending[at] && !state.syncing))
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
close: async () => {
|
|
277
|
+
closed = true;
|
|
278
|
+
for (const state of watched.values()) {
|
|
279
|
+
const watch = state.watch;
|
|
280
|
+
state.watch = undefined;
|
|
281
|
+
await watch?.stop();
|
|
282
|
+
}
|
|
283
|
+
await Promise.all([...watched.values()].map(state => state.chain));
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const CHARGES = new Set(['fallback-charge', 'usage-write-failed']);
|
|
288
|
+
const credits = (micros) => (micros / 1_000_000).toFixed(4);
|
|
289
|
+
const defaultRefusal = (available, hold) => `Not enough credits to send this message: ${credits(Math.max(0, available))} available, a message needs ${credits(hold)}.`;
|
|
290
|
+
const validHold = (hold) => { if (!Number.isSafeInteger(hold) || hold <= 0)
|
|
291
|
+
throw new TypeError('holdMicros must be a positive integer'); return hold; };
|
|
292
|
+
/** The checked policy of `options`: the hold of a reservation, the balance key of a scope, the refusal. */
|
|
293
|
+
function policyOf(options) {
|
|
294
|
+
const { holdMicros } = options;
|
|
295
|
+
if (typeof holdMicros === 'number')
|
|
296
|
+
validHold(holdMicros);
|
|
297
|
+
return {
|
|
298
|
+
hold: (input) => validHold(typeof holdMicros === 'number' ? holdMicros : holdMicros(input)),
|
|
299
|
+
key: (scope) => {
|
|
300
|
+
const key = options.balanceKey ? options.balanceKey(scope) : scope.userId;
|
|
301
|
+
if (typeof key !== 'string' || key === '')
|
|
302
|
+
throw new TypeError('balanceKey must return a non-empty string');
|
|
303
|
+
return key;
|
|
304
|
+
},
|
|
305
|
+
refuse: (available, hold, input) => ({ kind: 'refused', reason: (options.refusal ?? defaultRefusal)(available, hold, input) }),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const modelName = (model) => model ? `${model.provider}/${model.id}` : null;
|
|
309
|
+
/** The ledger in memory, for tests and examples: one process, lost on exit. */
|
|
310
|
+
export function createMemoryLedger(options) {
|
|
311
|
+
const policy = policyOf(options);
|
|
312
|
+
const grants = new Map();
|
|
313
|
+
const runs = new Map();
|
|
314
|
+
const charges = new Map();
|
|
315
|
+
const total = (items, keep) => [...items].filter(keep).reduce((sum, item) => sum + item.amount, 0);
|
|
316
|
+
const charged = (runId) => total(charges.values(), item => item.runId === runId);
|
|
317
|
+
const balance = async (key) => {
|
|
318
|
+
const granted = total(grants.values(), item => item.key === key), spent = total(charges.values(), item => item.key === key);
|
|
319
|
+
const held = [...runs.values()].filter(run => run.key === key && run.state === 'open').reduce((sum, run) => sum + Math.max(0, run.hold - charged(run.scope.runId)), 0);
|
|
320
|
+
return { balanceMicros: granted - spent, heldMicros: held, availableMicros: granted - spent - held };
|
|
321
|
+
};
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
balance,
|
|
324
|
+
grant: async (key, amountMicros, grantId) => { if (!grants.has(grantId))
|
|
325
|
+
grants.set(grantId, { key, amount: Math.trunc(amountMicros) }); },
|
|
326
|
+
reserveRun: async (input) => {
|
|
327
|
+
if (runs.has(input.runId))
|
|
328
|
+
return { reservationId: input.runId };
|
|
329
|
+
const key = policy.key(input), hold = policy.hold(input);
|
|
330
|
+
const { availableMicros } = await balance(key);
|
|
331
|
+
if (availableMicros < hold)
|
|
332
|
+
return policy.refuse(availableMicros, hold, input);
|
|
333
|
+
runs.set(input.runId, { scope: runScopeOf(input), key, hold, state: 'open' });
|
|
334
|
+
return { reservationId: input.runId };
|
|
335
|
+
},
|
|
336
|
+
recordUsage: async (input) => {
|
|
337
|
+
const id = JSON.stringify([input.runId, input.usageId]);
|
|
338
|
+
const key = runs.get(input.runId)?.key ?? policy.key(input);
|
|
339
|
+
if (!charges.has(id))
|
|
340
|
+
charges.set(id, { runId: input.runId, key, amount: Math.max(0, Math.ceil(input.amountMicros)) });
|
|
341
|
+
return { billedMicros: charges.get(id).amount };
|
|
342
|
+
},
|
|
343
|
+
settleRun: async (input) => { const run = runs.get(input.runId); if (run?.state === 'open')
|
|
344
|
+
run.state = 'settled'; },
|
|
345
|
+
releaseRun: async (input) => {
|
|
346
|
+
const run = runs.get(input.runId);
|
|
347
|
+
if (run?.state !== 'open')
|
|
348
|
+
return;
|
|
349
|
+
const charge = CHARGES.has(input.reason);
|
|
350
|
+
if (charge)
|
|
351
|
+
charges.set(JSON.stringify([input.runId, 'fallback']), { runId: input.runId, key: run.key, amount: Math.max(0, run.hold - charged(input.runId)) });
|
|
352
|
+
run.state = charge ? 'charged' : 'released';
|
|
353
|
+
},
|
|
354
|
+
openRuns: async () => [...runs.values()].filter(run => run.state === 'open').map(run => ({ ...run.scope, reservationId: run.scope.runId })),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The ledger in SQLite: grants, runs with their scope, model, kind and hold, and charges keyed by (run, step) with the usage's model,
|
|
359
|
+
* so a replayed usage report or fallback charge is never charged twice. Over a borrowed `SqliteConnection` (`openNodeConnection`,
|
|
360
|
+
* `durableObjectSqliteConnection`, ...), or a `filename` it opens with `sqlite` settings (`@hachej/boring-files/sqlite`: local disk WAL by
|
|
361
|
+
* default, `sqliteSettings.networkFilesystem` on EFS) and closes on `close()`. Every write is one write transaction (`BEGIN
|
|
362
|
+
* IMMEDIATE` on `node:sqlite`): several processes or requests on one ledger file wait for each other up to the busy timeout instead
|
|
363
|
+
* of failing, and a reservation's balance check and hold commit together, so concurrent reservations never spend the same credit twice.
|
|
364
|
+
*/
|
|
365
|
+
export function createSqliteLedger(options) {
|
|
366
|
+
const policy = policyOf(options);
|
|
367
|
+
const owned = options.connection === undefined;
|
|
368
|
+
if (owned && typeof options.filename !== 'string')
|
|
369
|
+
throw new TypeError('A SQLite ledger needs a connection or a filename');
|
|
370
|
+
const db = options.connection ?? openNodeConnection(options.filename, options.sqlite);
|
|
371
|
+
try {
|
|
372
|
+
db.transaction('write', () => db.exec(`CREATE TABLE IF NOT EXISTS boring_metering_grants (grant_id TEXT PRIMARY KEY, balance_key TEXT NOT NULL, amount INTEGER NOT NULL);
|
|
373
|
+
CREATE TABLE IF NOT EXISTS boring_metering_runs (run_id TEXT PRIMARY KEY, balance_key TEXT NOT NULL, user_id TEXT NOT NULL, workspace_id TEXT, attributes TEXT,
|
|
374
|
+
conversation_id INTEGER NOT NULL, request_id TEXT NOT NULL, model TEXT, kind TEXT NOT NULL, hold INTEGER NOT NULL, state TEXT NOT NULL, reason TEXT);
|
|
375
|
+
CREATE TABLE IF NOT EXISTS boring_metering_charges (run_id TEXT NOT NULL, step TEXT NOT NULL, balance_key TEXT NOT NULL, model TEXT, amount INTEGER NOT NULL, PRIMARY KEY (run_id, step));`));
|
|
376
|
+
// Tables of the earlier account-keyed shape are not migrated: refuse them rather than misread balances.
|
|
377
|
+
try {
|
|
378
|
+
db.all('SELECT balance_key FROM boring_metering_grants LIMIT 0');
|
|
379
|
+
db.all('SELECT balance_key, user_id FROM boring_metering_runs LIMIT 0');
|
|
380
|
+
db.all('SELECT balance_key FROM boring_metering_charges LIMIT 0');
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
throw new Error('The metering tables have the earlier account-keyed shape; move the old ledger aside (its balances are not migrated)');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
if (owned)
|
|
388
|
+
db.close?.();
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
const sum = (sql, ...params) => Number(db.get(sql, ...params)?.total ?? 0);
|
|
392
|
+
const balanceNow = (key) => {
|
|
393
|
+
const granted = sum('SELECT SUM(amount) AS total FROM boring_metering_grants WHERE balance_key = ?', key);
|
|
394
|
+
const spent = sum('SELECT SUM(amount) AS total FROM boring_metering_charges WHERE balance_key = ?', key);
|
|
395
|
+
const held = sum(`SELECT SUM(MAX(0, r.hold - COALESCE((SELECT SUM(c.amount) FROM boring_metering_charges c WHERE c.run_id = r.run_id), 0))) AS total
|
|
396
|
+
FROM boring_metering_runs r WHERE r.balance_key = ? AND r.state = 'open'`, key);
|
|
397
|
+
return { balanceMicros: granted - spent, heldMicros: held, availableMicros: granted - spent - held };
|
|
398
|
+
};
|
|
399
|
+
const insertCharge = 'INSERT OR IGNORE INTO boring_metering_charges (run_id, step, balance_key, model, amount) VALUES (?, ?, ?, ?, ?)';
|
|
400
|
+
const keyOfRun = (runId) => db.get('SELECT balance_key FROM boring_metering_runs WHERE run_id = ?', runId)?.balance_key;
|
|
401
|
+
return Object.freeze({
|
|
402
|
+
balance: async (key) => db.transaction('read', () => balanceNow(key)),
|
|
403
|
+
grant: async (key, amountMicros, grantId) => db.transaction('write', () => {
|
|
404
|
+
db.run('INSERT OR IGNORE INTO boring_metering_grants (grant_id, balance_key, amount) VALUES (?, ?, ?)', grantId, key, Math.trunc(amountMicros));
|
|
405
|
+
}),
|
|
406
|
+
reserveRun: async (input) => {
|
|
407
|
+
const key = policy.key(input), hold = policy.hold(input);
|
|
408
|
+
return db.transaction('write', () => {
|
|
409
|
+
if (db.get('SELECT 1 AS found FROM boring_metering_runs WHERE run_id = ?', input.runId))
|
|
410
|
+
return { reservationId: input.runId };
|
|
411
|
+
const { availableMicros } = balanceNow(key);
|
|
412
|
+
if (availableMicros < hold)
|
|
413
|
+
return policy.refuse(availableMicros, hold, input);
|
|
414
|
+
db.run(`INSERT INTO boring_metering_runs (run_id, balance_key, user_id, workspace_id, attributes, conversation_id, request_id, model, kind, hold, state)
|
|
415
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')`, input.runId, key, input.userId, input.workspaceId ?? null, input.attributes ? JSON.stringify(input.attributes) : null, input.conversationId, input.requestId, modelName(input.model), input.kind, hold);
|
|
416
|
+
return { reservationId: input.runId };
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
recordUsage: async (input) => db.transaction('write', () => {
|
|
420
|
+
db.run(insertCharge, input.runId, input.usageId, keyOfRun(input.runId) ?? policy.key(input), modelName(input.model), Math.max(0, Math.ceil(input.amountMicros)));
|
|
421
|
+
return { billedMicros: Number(db.get('SELECT amount FROM boring_metering_charges WHERE run_id = ? AND step = ?', input.runId, input.usageId).amount) };
|
|
422
|
+
}),
|
|
423
|
+
settleRun: async (input) => db.transaction('write', () => {
|
|
424
|
+
db.run(`UPDATE boring_metering_runs SET state = 'settled', reason = ? WHERE run_id = ? AND state = 'open'`, input.status, input.runId);
|
|
425
|
+
}),
|
|
426
|
+
releaseRun: async (input) => db.transaction('write', () => {
|
|
427
|
+
const run = db.get(`SELECT balance_key, hold FROM boring_metering_runs WHERE run_id = ? AND state = 'open'`, input.runId);
|
|
428
|
+
if (!run)
|
|
429
|
+
return;
|
|
430
|
+
const charge = CHARGES.has(input.reason);
|
|
431
|
+
if (charge)
|
|
432
|
+
db.run(insertCharge, input.runId, 'fallback', run.balance_key, null, Math.max(0, Number(run.hold) - sum('SELECT SUM(amount) AS total FROM boring_metering_charges WHERE run_id = ?', input.runId)));
|
|
433
|
+
db.run('UPDATE boring_metering_runs SET state = ?, reason = ? WHERE run_id = ?', charge ? 'charged' : 'released', input.reason, input.runId);
|
|
434
|
+
}),
|
|
435
|
+
openRuns: async () => db.all(`SELECT run_id, user_id, workspace_id, attributes, conversation_id, request_id FROM boring_metering_runs WHERE state = 'open' ORDER BY rowid`)
|
|
436
|
+
.map(row => ({ userId: row.user_id, ...(row.workspace_id === null ? {} : { workspaceId: row.workspace_id }),
|
|
437
|
+
...(row.attributes === null ? {} : { attributes: JSON.parse(row.attributes) }),
|
|
438
|
+
conversationId: Number(row.conversation_id), requestId: row.request_id, runId: row.run_id, reservationId: row.run_id })),
|
|
439
|
+
/** Closes the file the ledger opened; a borrowed connection stays open (its owner closes it). */
|
|
440
|
+
close: () => { if (owned)
|
|
441
|
+
db.close?.(); },
|
|
442
|
+
});
|
|
443
|
+
}
|