@atcn/subledger 1.5.0 → 1.5.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/dist/documents.d.ts +1 -1
- package/dist/documents.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/presets.d.ts +36 -0
- package/dist/presets.js +192 -0
- package/package.json +3 -3
package/dist/documents.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { z } from "zod";
|
|
|
12
12
|
export declare const SUBLEDGER_SCHEMA_VERSION: "1.5";
|
|
13
13
|
export declare const SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS: readonly ["1.2", "1.3", "1.4", "1.5"];
|
|
14
14
|
/** Must equal this package's version in package.json (checked by a test). */
|
|
15
|
-
export declare const SUBLEDGER_VERIFIER_VERSION: "1.5.
|
|
15
|
+
export declare const SUBLEDGER_VERIFIER_VERSION: "1.5.1";
|
|
16
16
|
export declare const RECEIPT_DOCUMENT_TYPE: "atcn.subledger.receipt";
|
|
17
17
|
export declare const CLOSURE_DOCUMENT_TYPE: "atcn.subledger.closure";
|
|
18
18
|
export declare const RESPONSE_STATEMENT_TYPE: "atcn.subledger.receipt_response";
|
package/dist/documents.js
CHANGED
|
@@ -14,7 +14,7 @@ import { SubledgerWitnessPolicySchema, ExpectationSchema, KeySignerSchema, RailA
|
|
|
14
14
|
export const SUBLEDGER_SCHEMA_VERSION = "1.5";
|
|
15
15
|
export const SUPPORTED_SUBLEDGER_SCHEMA_VERSIONS = ["1.2", "1.3", "1.4", "1.5"];
|
|
16
16
|
/** Must equal this package's version in package.json (checked by a test). */
|
|
17
|
-
export const SUBLEDGER_VERIFIER_VERSION = "1.5.
|
|
17
|
+
export const SUBLEDGER_VERIFIER_VERSION = "1.5.1";
|
|
18
18
|
export const RECEIPT_DOCUMENT_TYPE = "atcn.subledger.receipt";
|
|
19
19
|
export const CLOSURE_DOCUMENT_TYPE = "atcn.subledger.closure";
|
|
20
20
|
export const RESPONSE_STATEMENT_TYPE = "atcn.subledger.receipt_response";
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ImportResult, type ImportRow } from "./importing.js";
|
|
2
|
+
/**
|
|
3
|
+
* Ready-made mappings for widely used cost exports, so no column map is needed.
|
|
4
|
+
*
|
|
5
|
+
* - litellm: LiteLLM proxy spend logs (`/spend/logs/v2` or `/spend/logs`). The job reference is `end_user`, the
|
|
6
|
+
* OpenAI `user` field the agent sent with the request.
|
|
7
|
+
* - openrouter: OpenRouter analytics query rows (`POST /api/v1/analytics/query`) with metric `total_usage`, dimension
|
|
8
|
+
* `external_user` and granularity `day`. The job reference is `external_user`, the `user` field of the request.
|
|
9
|
+
* - stripe: Stripe itemized balance report (`balance_change_from_activity.itemized`) with the extra columns
|
|
10
|
+
* `payment_metadata[atcn_job_ref]` and `transfer_metadata[atcn_job_ref]`.
|
|
11
|
+
*
|
|
12
|
+
* LLM spend is reported in fractions of a cent, so it is summed exactly per job reference per UTC day and rounded half
|
|
13
|
+
* up to whole cents once. Re-importing a finished day replays the same events; importing a day again after more
|
|
14
|
+
* requests landed changes its amount and is refused as duplicate_event, so import closed days.
|
|
15
|
+
*/
|
|
16
|
+
export type ImportPreset = "litellm" | "openrouter" | "stripe";
|
|
17
|
+
export declare const IMPORT_PRESETS: readonly ImportPreset[];
|
|
18
|
+
/** Metadata key on Stripe PaymentIntents and transfers that holds the ATCN job reference. */
|
|
19
|
+
export declare const STRIPE_JOB_REF_KEY = "atcn_job_ref";
|
|
20
|
+
export interface PresetOptions {
|
|
21
|
+
/** The financial event source (default: the preset name). */
|
|
22
|
+
source?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PresetImportResult extends ImportResult {
|
|
25
|
+
/** Rows that are valid but carry no job cost, for example a Stripe payout or a request sent without a user field. */
|
|
26
|
+
skipped: {
|
|
27
|
+
row: number;
|
|
28
|
+
reason: string;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/** Rows of a JSON export: an array of objects, or an object wrapping one in `data` (LiteLLM) or `data.data` (OpenRouter). */
|
|
32
|
+
export declare function exportRowsFromJson(value: unknown): ImportRow[];
|
|
33
|
+
/** Reads an export saved as JSON, JSONL or CSV. */
|
|
34
|
+
export declare function parseExportText(text: string): ImportRow[];
|
|
35
|
+
/** Converts rows of a known export into financial event bodies. */
|
|
36
|
+
export declare function importPresetRows(preset: ImportPreset, rows: ImportRow[], options?: PresetOptions): PresetImportResult;
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { derivedSourceEventId, majorToMinor, parseCsvRows, parseJsonlRows } from "./importing.js";
|
|
2
|
+
export const IMPORT_PRESETS = ["litellm", "openrouter", "stripe"];
|
|
3
|
+
/** Metadata key on Stripe PaymentIntents and transfers that holds the ATCN job reference. */
|
|
4
|
+
export const STRIPE_JOB_REF_KEY = "atcn_job_ref";
|
|
5
|
+
/** Rows of a JSON export: an array of objects, or an object wrapping one in `data` (LiteLLM) or `data.data` (OpenRouter). */
|
|
6
|
+
export function exportRowsFromJson(value) {
|
|
7
|
+
let items = value;
|
|
8
|
+
if (isObject(items) && "data" in items)
|
|
9
|
+
items = items.data;
|
|
10
|
+
if (isObject(items) && "data" in items)
|
|
11
|
+
items = items.data;
|
|
12
|
+
if (isObject(items))
|
|
13
|
+
items = [items];
|
|
14
|
+
if (!Array.isArray(items))
|
|
15
|
+
throw new Error("JSON export must be an array of rows or an object with a data array");
|
|
16
|
+
return items.map((item, index) => {
|
|
17
|
+
if (!isObject(item))
|
|
18
|
+
throw new Error(`row ${index + 1} is not a JSON object`);
|
|
19
|
+
return item;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Reads an export saved as JSON, JSONL or CSV. */
|
|
23
|
+
export function parseExportText(text) {
|
|
24
|
+
const trimmed = text.trim();
|
|
25
|
+
if (!trimmed.startsWith("[") && !trimmed.startsWith("{"))
|
|
26
|
+
return parseCsvRows(text);
|
|
27
|
+
let value;
|
|
28
|
+
try {
|
|
29
|
+
value = JSON.parse(trimmed);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return parseJsonlRows(text);
|
|
33
|
+
}
|
|
34
|
+
return exportRowsFromJson(value);
|
|
35
|
+
}
|
|
36
|
+
/** Converts rows of a known export into financial event bodies. */
|
|
37
|
+
export function importPresetRows(preset, rows, options = {}) {
|
|
38
|
+
const source = options.source ?? preset;
|
|
39
|
+
if (preset === "litellm")
|
|
40
|
+
return importDailySpend(rows, { ref: "end_user", date: "startTime", amount: "spend" }, source);
|
|
41
|
+
if (preset === "openrouter")
|
|
42
|
+
return importDailySpend(rows, { ref: "external_user", date: "date__day", amount: "total_usage" }, source);
|
|
43
|
+
return importStripeBalanceRows(rows, source);
|
|
44
|
+
}
|
|
45
|
+
/** Fixed-point scale for summing sub-cent USD spend: 18 decimal places. */
|
|
46
|
+
const SPEND_SCALE = 18;
|
|
47
|
+
function importDailySpend(rows, columns, source) {
|
|
48
|
+
const result = { events: [], errors: [], skipped: [] };
|
|
49
|
+
const groups = new Map();
|
|
50
|
+
rows.forEach((row, index) => {
|
|
51
|
+
const number = index + 1;
|
|
52
|
+
const ref = text(row[columns.ref]);
|
|
53
|
+
if (ref === null) {
|
|
54
|
+
result.skipped.push({ row: number, reason: `no ${columns.ref}: send the ATCN job reference as the request's user field` });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const day = utcIso(row[columns.date], columns.date).slice(0, 10);
|
|
59
|
+
const amount = spendToScaled(row[columns.amount], columns.amount);
|
|
60
|
+
const key = JSON.stringify([ref, day]);
|
|
61
|
+
const group = groups.get(key) ?? { row: number, ref, day, total: 0n };
|
|
62
|
+
group.total += amount;
|
|
63
|
+
groups.set(key, group);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
result.errors.push({ row: number, error: error.message });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
const cent = 10n ** BigInt(SPEND_SCALE - 2);
|
|
70
|
+
for (const group of groups.values()) {
|
|
71
|
+
const amountMinor = Number((group.total + cent / 2n) / cent);
|
|
72
|
+
if (amountMinor === 0) {
|
|
73
|
+
result.skipped.push({ row: group.row, reason: `spend for ${group.ref} on ${group.day} rounds to 0 cents` });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
result.events.push({
|
|
77
|
+
row: group.row,
|
|
78
|
+
event: {
|
|
79
|
+
type: "charge",
|
|
80
|
+
source,
|
|
81
|
+
source_event_id: derivedSourceEventId({ provider_job_ref: group.ref, day: group.day }),
|
|
82
|
+
provider_reference: null,
|
|
83
|
+
provider_status: null,
|
|
84
|
+
amount_minor: amountMinor,
|
|
85
|
+
currency: "USD",
|
|
86
|
+
event_date: `${group.day}T00:00:00.000Z`,
|
|
87
|
+
match: { provider_job_ref: group.ref, task_external_ref: null, delegation_external_ref: null },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
result.events.sort((a, b) => a.row - b.row);
|
|
92
|
+
result.skipped.sort((a, b) => a.row - b.row);
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/** A non-negative decimal (a JSON number or a string, exponent allowed) as an integer at SPEND_SCALE. */
|
|
96
|
+
function spendToScaled(value, column) {
|
|
97
|
+
const decimal = typeof value === "number" ? String(value) : typeof value === "string" ? value.trim() : "";
|
|
98
|
+
const match = /^(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(decimal);
|
|
99
|
+
if (!match || (match[1] === "" && !match[2]))
|
|
100
|
+
throw new Error(`${column} ${JSON.stringify(value)} is not a non-negative decimal number`);
|
|
101
|
+
const [, whole, fraction = "", exponent = "0"] = match;
|
|
102
|
+
const digits = BigInt(whole + fraction || "0");
|
|
103
|
+
const shift = SPEND_SCALE - fraction.length + Number(exponent);
|
|
104
|
+
if (shift >= 0)
|
|
105
|
+
return digits * 10n ** BigInt(shift);
|
|
106
|
+
const divisor = 10n ** BigInt(-shift);
|
|
107
|
+
return (digits + divisor / 2n) / divisor;
|
|
108
|
+
}
|
|
109
|
+
/** Stripe reporting categories that are job cost, the event type each becomes, and the column holding the job reference. */
|
|
110
|
+
const STRIPE_CATEGORIES = {
|
|
111
|
+
charge: { type: "charge", refColumn: `payment_metadata[${STRIPE_JOB_REF_KEY}]` },
|
|
112
|
+
refund: { type: "refund", refColumn: `payment_metadata[${STRIPE_JOB_REF_KEY}]` },
|
|
113
|
+
transfer: { type: "payment_reported", refColumn: `transfer_metadata[${STRIPE_JOB_REF_KEY}]` },
|
|
114
|
+
// A reversal event needs the ATCN id of the event it reverses, which the export cannot know: money a provider
|
|
115
|
+
// returns from a transfer is recorded as a refund.
|
|
116
|
+
transfer_reversal: { type: "refund", refColumn: `transfer_metadata[${STRIPE_JOB_REF_KEY}]` },
|
|
117
|
+
};
|
|
118
|
+
/** ISO 4217 currencies Stripe reports without minor units, and those with three decimal places. */
|
|
119
|
+
const ZERO_DECIMAL_CURRENCIES = ["BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"];
|
|
120
|
+
const THREE_DECIMAL_CURRENCIES = ["BHD", "JOD", "KWD", "OMR", "TND"];
|
|
121
|
+
function importStripeBalanceRows(rows, source) {
|
|
122
|
+
const result = { events: [], errors: [], skipped: [] };
|
|
123
|
+
rows.forEach((row, index) => {
|
|
124
|
+
const number = index + 1;
|
|
125
|
+
const category = text(row.reporting_category) ?? "";
|
|
126
|
+
const mapping = STRIPE_CATEGORIES[category];
|
|
127
|
+
if (!mapping) {
|
|
128
|
+
result.skipped.push({ row: number, reason: `reporting_category ${category || "(empty)"} is not job cost` });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const ref = text(row[mapping.refColumn]);
|
|
132
|
+
if (ref === null) {
|
|
133
|
+
result.skipped.push({ row: number, reason: `no ${mapping.refColumn}` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const sourceEventId = text(row.balance_transaction_id);
|
|
138
|
+
if (sourceEventId === null)
|
|
139
|
+
throw new Error("missing balance_transaction_id");
|
|
140
|
+
const currency = (text(row.currency) ?? "").toUpperCase();
|
|
141
|
+
if (!/^[A-Z]{3}$/.test(currency))
|
|
142
|
+
throw new Error(`currency ${JSON.stringify(row.currency ?? null)} is not an ISO 4217 code`);
|
|
143
|
+
const gross = text(row.gross);
|
|
144
|
+
if (gross === null)
|
|
145
|
+
throw new Error("missing gross");
|
|
146
|
+
const created = row.created_utc ?? row.created;
|
|
147
|
+
result.events.push({
|
|
148
|
+
row: number,
|
|
149
|
+
event: {
|
|
150
|
+
type: mapping.type,
|
|
151
|
+
source,
|
|
152
|
+
source_event_id: sourceEventId,
|
|
153
|
+
provider_reference: text(row.source_id),
|
|
154
|
+
provider_status: null,
|
|
155
|
+
amount_minor: Math.abs(stripeMajorToMinor(gross, currency)),
|
|
156
|
+
currency,
|
|
157
|
+
event_date: utcIso(created, row.created_utc !== undefined ? "created_utc" : "created"),
|
|
158
|
+
match: { provider_job_ref: ref, task_external_ref: null, delegation_external_ref: null },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
result.errors.push({ row: number, error: error.message });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
/** Stripe reports amounts in major units; zeros past the currency's decimal places (JPY "500.00") are dropped. */
|
|
169
|
+
function stripeMajorToMinor(value, currency) {
|
|
170
|
+
const digits = ZERO_DECIMAL_CURRENCIES.includes(currency) ? 0 : THREE_DECIMAL_CURRENCIES.includes(currency) ? 3 : 2;
|
|
171
|
+
const [whole, fraction = ""] = value.split(".");
|
|
172
|
+
const trimmedFraction = fraction.length > digits ? fraction.slice(0, digits) + fraction.slice(digits).replace(/0+$/, "") : fraction;
|
|
173
|
+
return majorToMinor(trimmedFraction ? `${whole}.${trimmedFraction}` : whole, digits);
|
|
174
|
+
}
|
|
175
|
+
/** An ISO timestamp; a date or date-time without a zone is read as UTC, as LiteLLM and Stripe write them. */
|
|
176
|
+
function utcIso(value, column) {
|
|
177
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
178
|
+
const zoneless = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$/.test(raw);
|
|
179
|
+
const time = Date.parse(zoneless ? `${raw.replace(" ", "T")}${raw.length > 10 ? "Z" : ""}` : raw);
|
|
180
|
+
if (raw === "" || Number.isNaN(time))
|
|
181
|
+
throw new Error(`${column} ${JSON.stringify(value ?? null)} is not a date`);
|
|
182
|
+
return new Date(time).toISOString();
|
|
183
|
+
}
|
|
184
|
+
function text(value) {
|
|
185
|
+
if (value === undefined || value === null)
|
|
186
|
+
return null;
|
|
187
|
+
const result = String(value).trim();
|
|
188
|
+
return result === "" ? null : result;
|
|
189
|
+
}
|
|
190
|
+
function isObject(value) {
|
|
191
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atcn/subledger",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "The cost record for an AI agent job: matches charges to delegated work, flags what doesn't add up, and signs closures anyone can verify offline",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"build": "tsc -p tsconfig.build.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@atcn/core": "1.5.
|
|
37
|
-
"@atcn/schema": "1.5.
|
|
36
|
+
"@atcn/core": "1.5.1",
|
|
37
|
+
"@atcn/schema": "1.5.1",
|
|
38
38
|
"@noble/curves": "^1.9.0",
|
|
39
39
|
"@noble/hashes": "^1.8.0",
|
|
40
40
|
"zod": "^4.1.0"
|