@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// What a service spends on models (DEC-0021, DEC-0065, DEC-0091): receipts, the ledger, the usage report and its rules (FAC-SEM-025), OpenRouter attribution (FAC-SEM-098), spending limits (DEC-0027).
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_usage.py. Node 20+, no dependency.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { normalIdentifier } from './fabric-identifiers.mjs';
|
|
6
|
+
import { parseRfc3339Ms as strictTime, utcDateMs, utcDay as utcDate } from './fabric-time.mjs'; // the core's (FAA-65)
|
|
7
|
+
const emptyRow = (extra = {}) => ({ ...extra, calls: 0, unpricedCalls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0 });
|
|
8
|
+
import { show, toNumber } from './fabric-jsvalue.mjs';
|
|
9
|
+
import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
|
|
10
|
+
import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
|
|
11
|
+
|
|
12
|
+
export const ROUTES = Object.freeze(['subscription', 'key']);
|
|
13
|
+
|
|
14
|
+
/** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
|
|
15
|
+
export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
|
|
16
|
+
|
|
17
|
+
export const USAGE_PATH = '/fabric/v1/usage';
|
|
18
|
+
|
|
19
|
+
export const USAGE_DAYS = 31;
|
|
20
|
+
|
|
21
|
+
export const COST_BASES = ['provider', 'price-list', 'unknown'];
|
|
22
|
+
|
|
23
|
+
const PROVIDER = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
24
|
+
|
|
25
|
+
const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
29
|
+
* `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* One model call. `projectId` (a URI, kept in its normal form, DEC-0076) and `taskRunId` (the service's own run id) attribute
|
|
33
|
+
* it to the project the service ran it for (DEC-0065); they never name a caller, session or person.
|
|
34
|
+
*/
|
|
35
|
+
// tokensMeasured: false marks a call whose token counts are a lower bound or unknown (DEC-0091): the report counts it
|
|
36
|
+
// in unmeasuredCalls and its token totals are a lower bound.
|
|
37
|
+
export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId, tokensMeasured = true } = {}) {
|
|
38
|
+
if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
|
|
39
|
+
model = String(model ?? '').trim();
|
|
40
|
+
if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
|
|
41
|
+
const counts = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
|
|
42
|
+
for (const [name, value] of Object.entries(counts)) if (!Number.isInteger(value) || value < 0) throw new ServiceError(`${name} must be a non-negative integer.`);
|
|
43
|
+
if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
|
|
44
|
+
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
45
|
+
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
46
|
+
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
47
|
+
if (tokensMeasured !== true && tokensMeasured !== false) throw new ServiceError('tokensMeasured is true or false.');
|
|
48
|
+
if (taskRunId !== undefined && projectId === undefined) throw new ServiceError('a task run belongs to a project: give projectId with taskRunId.');
|
|
49
|
+
if (projectId !== undefined && !(typeof projectId === 'string' && /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(projectId))) throw new ServiceError('projectId is the URI of the project the service ran the call for.');
|
|
50
|
+
if (taskRunId !== undefined && !(typeof taskRunId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(taskRunId))) throw new ServiceError("taskRunId is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.");
|
|
51
|
+
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(tokensMeasured ? {} : { tokensMeasured: false }), ...(route === undefined ? {} : { route }),
|
|
52
|
+
...(projectId === undefined ? {} : { projectId: normalIdentifier(projectId) }), ...(taskRunId === undefined ? {} : { taskRunId }) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** DEC-0065's `byProject`: the window's calls by project (normal form, DEC-0076), each task run once within it. */
|
|
56
|
+
function byProject(receipts, first, last) {
|
|
57
|
+
const projects = new Map();
|
|
58
|
+
const add = (row, r) => { row.calls += 1; if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd; };
|
|
59
|
+
for (const r of receipts) {
|
|
60
|
+
const date = String(r.at ?? '').slice(0, 10);
|
|
61
|
+
if (date < first || date > last || !r.projectId) continue;
|
|
62
|
+
if (!projects.has(r.projectId)) projects.set(r.projectId, { projectId: r.projectId, calls: 0, unpricedCalls: 0, cost: 0, runs: new Map() });
|
|
63
|
+
const row = projects.get(r.projectId);
|
|
64
|
+
add(row, r);
|
|
65
|
+
if (r.taskRunId) {
|
|
66
|
+
if (!row.runs.has(r.taskRunId)) row.runs.set(r.taskRunId, { taskRunId: r.taskRunId, calls: 0, unpricedCalls: 0, cost: 0 });
|
|
67
|
+
add(row.runs.get(r.taskRunId), r);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
|
|
71
|
+
return [...projects.keys()].sort().map((key) => {
|
|
72
|
+
const { runs, ...row } = projects.get(key);
|
|
73
|
+
const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
|
|
74
|
+
return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function closeRow(row) {
|
|
79
|
+
const { cost, ...rest } = row;
|
|
80
|
+
return { ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The service-usage.schema.json answer: the last 31 UTC days, oldest first, per provider and
|
|
85
|
+
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
86
|
+
* are the sums of the model rows (FAC-SEM-025).
|
|
87
|
+
*/
|
|
88
|
+
const usageCount = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : 0);
|
|
89
|
+
|
|
90
|
+
const usageCost = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
91
|
+
|
|
92
|
+
const exceeds = (a, b) => a - b > 0.000001 + 1e-9 * Math.max(Math.abs(a), Math.abs(b));
|
|
93
|
+
|
|
94
|
+
const usd = (v) => String(Math.round(v * 1e6) / 1e6);
|
|
95
|
+
|
|
96
|
+
const usageList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'object' && x !== null && !Array.isArray(x)) : []);
|
|
97
|
+
|
|
98
|
+
function pricedRow(row, at) {
|
|
99
|
+
const calls = usageCount(row.calls), unpriced = usageCount(row.unpricedCalls);
|
|
100
|
+
const flag = (p, message) => [{ code: 'FAC-SEM-025', instancePath: p, message }];
|
|
101
|
+
if (unpriced > calls) return flag(`${at}/unpricedCalls`, 'more unpriced calls than calls');
|
|
102
|
+
// DEC-0091: calls whose token count is unknown are some of the calls, never more.
|
|
103
|
+
if (usageCount(row.unmeasuredCalls) > calls) return flag(`${at}/unmeasuredCalls`, 'more calls with unknown tokens than calls');
|
|
104
|
+
if (calls > 0 && unpriced === calls && row.costUsd !== null) return flag(`${at}/costUsd`, 'every call is unpriced, so the cost is unknown (null), not a number');
|
|
105
|
+
if (unpriced < calls && row.costUsd === null) return flag(`${at}/costUsd`, 'priced calls carry a cost; null is only for a row with no priced call');
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic, the window (every
|
|
111
|
+
* date exists, not after `generatedAt`'s UTC date, at most 30 days before it) and the project attribution (each project
|
|
112
|
+
* once by its normal form, each task run once; calls, priced calls and known costs bounded). Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function usageFindings(report) {
|
|
115
|
+
const value = typeof report === 'object' && report !== null && !Array.isArray(report) ? report : {};
|
|
116
|
+
const findings = [];
|
|
117
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-025', instancePath, message });
|
|
118
|
+
const days = usageList(value.days);
|
|
119
|
+
let previous = '';
|
|
120
|
+
days.forEach((day, i) => {
|
|
121
|
+
const at = `/days/${i}`;
|
|
122
|
+
const date = show(day.date ?? '');
|
|
123
|
+
if (date <= previous) flag(`${at}/date`, `days must run forward without a repeat (${date} after ${previous})`);
|
|
124
|
+
previous = date;
|
|
125
|
+
findings.push(...pricedRow(day, at));
|
|
126
|
+
const models = usageList(day.byModel);
|
|
127
|
+
models.forEach((m, j) => findings.push(...pricedRow(m, `${at}/byModel/${j}`)));
|
|
128
|
+
if (!models.length) {
|
|
129
|
+
const spent = ['calls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some((f) => toNumber(day[f] ?? 0) > 0) || toNumber(day.costUsd ?? 0) > 0;
|
|
130
|
+
if (spent) flag(`${at}/byModel`, 'a day with calls, tokens or cost names the models they belong to');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const field of ['calls', 'unpricedCalls', 'unmeasuredCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
134
|
+
const sum = models.reduce((n, m) => n + toNumber(m[field] ?? 0), 0);
|
|
135
|
+
if (toNumber(day[field] ?? 0) !== sum) flag(`${at}/${field}`, `the day's ${field} is not the sum of its models (${show(day[field] ?? 0)} ≠ ${sum})`);
|
|
136
|
+
}
|
|
137
|
+
const known = models.map((m) => (typeof m.costUsd === 'number' ? m.costUsd : null)).filter((x) => x !== null).reduce((n, x) => n + x, 0);
|
|
138
|
+
// The contract's !close(day, known): Infinity or NaN is never close, so it is a finding.
|
|
139
|
+
if (typeof day.costUsd === 'number' && !(Math.abs(day.costUsd - known) <= 0.000001 + 1e-9 * Math.max(Math.abs(day.costUsd), Math.abs(known)))) flag(`${at}/costUsd`, "the day's cost is not the sum of its models' known costs");
|
|
140
|
+
});
|
|
141
|
+
if (days.length) {
|
|
142
|
+
const generated = strictTime(value.generatedAt);
|
|
143
|
+
if (Number.isNaN(generated)) flag('/generatedAt', 'generatedAt is an RFC 3339 date-time with an offset, so the window can be read');
|
|
144
|
+
else {
|
|
145
|
+
const today = Math.floor(generated / 86_400_000) * 86_400_000;
|
|
146
|
+
const oldest = today - 30 * 86_400_000;
|
|
147
|
+
days.forEach((day, i) => {
|
|
148
|
+
const date = utcDateMs(day.date);
|
|
149
|
+
if (Number.isNaN(date)) flag(`/days/${i}/date`, "a day's date exists in its month");
|
|
150
|
+
else if (date > today) flag(`/days/${i}/date`, 'a day is not after the UTC date of generatedAt');
|
|
151
|
+
else if (date < oldest) flag(`/days/${i}/date`, 'a day is within the last 31 UTC days of generatedAt');
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(value.byProject)) {
|
|
156
|
+
const seen = new Map();
|
|
157
|
+
let projectCalls = 0, projectCost = 0;
|
|
158
|
+
value.byProject.forEach((project, p) => {
|
|
159
|
+
if (typeof project !== 'object' || project === null || Array.isArray(project)) return;
|
|
160
|
+
const at = `/byProject/${p}`;
|
|
161
|
+
const normal = normalIdentifier(project.projectId);
|
|
162
|
+
if (normal !== undefined) {
|
|
163
|
+
if (seen.has(normal)) flag(`${at}/projectId`, `each project is one row: ${show(project.projectId)} names the project ${seen.get(normal)} already names`);
|
|
164
|
+
else seen.set(normal, show(project.projectId));
|
|
165
|
+
}
|
|
166
|
+
projectCalls += usageCount(project.calls);
|
|
167
|
+
projectCost += usageCost(project.costUsd);
|
|
168
|
+
findings.push(...pricedRow(project, at));
|
|
169
|
+
const runs = new Set();
|
|
170
|
+
let runCalls = 0, runCost = 0, runPriced = 0;
|
|
171
|
+
usageList(project.taskRuns).forEach((run, r) => {
|
|
172
|
+
if (typeof run.taskRunId === 'string') {
|
|
173
|
+
if (runs.has(run.taskRunId)) flag(`${at}/taskRuns/${r}/taskRunId`, 'each task run is one row within its project');
|
|
174
|
+
runs.add(run.taskRunId);
|
|
175
|
+
}
|
|
176
|
+
runCalls += usageCount(run.calls);
|
|
177
|
+
runCost += usageCost(run.costUsd);
|
|
178
|
+
runPriced += Math.max(0, usageCount(run.calls) - usageCount(run.unpricedCalls));
|
|
179
|
+
findings.push(...pricedRow(run, `${at}/taskRuns/${r}`));
|
|
180
|
+
});
|
|
181
|
+
const projectPriced = Math.max(0, usageCount(project.calls) - usageCount(project.unpricedCalls));
|
|
182
|
+
if (runCalls > usageCount(project.calls)) flag(`${at}/taskRuns`, `a project's task runs hold more calls than the project (${runCalls} > ${usageCount(project.calls)})`);
|
|
183
|
+
else if (runPriced > projectPriced) flag(`${at}/taskRuns`, `a project's task runs hold more priced calls than the project (${runPriced} > ${projectPriced})`);
|
|
184
|
+
else if (usageCost(project.costUsd) === project.costUsd && exceeds(runCost, usageCost(project.costUsd))) flag(`${at}/taskRuns`, `a project's task runs cost more than the project (${usd(runCost)} > ${usd(usageCost(project.costUsd))})`);
|
|
185
|
+
});
|
|
186
|
+
const dayCalls = days.reduce((n, d) => n + usageCount(d.calls), 0);
|
|
187
|
+
if (projectCalls > dayCalls) flag('/byProject', `the projects hold more calls than the days (${projectCalls} > ${dayCalls})`);
|
|
188
|
+
const dayCost = days.reduce((n, d) => n + usageCost(d.costUsd), 0);
|
|
189
|
+
if (exceeds(projectCost, dayCost)) flag('/byProject', `the projects cost more than the days (${usd(projectCost)} > ${usd(dayCost)})`);
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const OPENROUTER_CATEGORIES = Object.freeze(['cli-agent', 'ide-extension', 'cloud-agent', 'programming-app', 'native-app-builder',
|
|
195
|
+
'creative-writing', 'video-gen', 'image-gen', 'audio-gen', 'writing-assistant', 'general-chat', 'personal-agent', 'legal', 'roleplay', 'game']);
|
|
196
|
+
|
|
197
|
+
const ATTR_PRODUCT = /^[a-z][a-z0-9-]{0,62}$/;
|
|
198
|
+
|
|
199
|
+
const ATTR_TITLE = /^[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?$/;
|
|
200
|
+
|
|
201
|
+
const ATTRIBUTION_HEADER_NAMES = new Set(['http-referer', 'referer', 'x-openrouter-title', 'x-title', 'x-openrouter-categories']);
|
|
202
|
+
|
|
203
|
+
/** FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories. */
|
|
204
|
+
export function attributionSettingFindings(value) {
|
|
205
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return [{ code: 'FAC-SEM-098', instancePath: '', message: 'an attribution setting is an object' }];
|
|
206
|
+
const findings = [];
|
|
207
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-098', instancePath, message });
|
|
208
|
+
if (typeof value.product !== 'string' || !ATTR_PRODUCT.test(value.product)) flag('/product', 'the product is a lower-case slug, the last segment of its product page');
|
|
209
|
+
if (typeof value.title !== 'string' || !ATTR_TITLE.test(value.title)) flag('/title', "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end");
|
|
210
|
+
if (value.categories !== undefined) {
|
|
211
|
+
if (!Array.isArray(value.categories)) flag('/categories', 'categories are a list');
|
|
212
|
+
else {
|
|
213
|
+
if (value.categories.length > 2) flag('/categories', 'at most 2 categories per request');
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
value.categories.forEach((c, i) => {
|
|
216
|
+
if (typeof c !== 'string' || !OPENROUTER_CATEGORIES.includes(c)) flag(`/categories/${i}`, 'a category is one OpenRouter recognizes; it drops others without an error');
|
|
217
|
+
else if (seen.has(c)) flag(`/categories/${i}`, 'each category once');
|
|
218
|
+
else seen.add(c);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isOpenRouter(url) {
|
|
226
|
+
try {
|
|
227
|
+
const u = new URL(url);
|
|
228
|
+
return u.protocol === 'https:' && u.hostname === 'openrouter.ai' && u.port === '' && u.username === '' && u.password === '';
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
|
|
236
|
+
* `no-setting` (off by default), `invalid-setting`, `not-openrouter`, `forwarded`, `user-set`. Only `HTTP-Referer`,
|
|
237
|
+
* `X-OpenRouter-Title` and `X-OpenRouter-Categories`; it never reads, moves or returns a key.
|
|
238
|
+
*/
|
|
239
|
+
export function attributionHeaders(setting, given) {
|
|
240
|
+
const request = typeof given === 'object' && given !== null ? given : {};
|
|
241
|
+
if (setting === undefined || setting === null) return { headers: {}, none: 'no-setting' };
|
|
242
|
+
if (attributionSettingFindings(setting).length) return { headers: {}, none: 'invalid-setting' };
|
|
243
|
+
if (typeof request.url !== 'string' || !isOpenRouter(request.url)) return { headers: {}, none: 'not-openrouter' };
|
|
244
|
+
if (request.originated !== true) return { headers: {}, none: 'forwarded' };
|
|
245
|
+
const present = typeof request.headers === 'object' && request.headers !== null ? Object.keys(request.headers).map((n) => n.toLowerCase()) : [];
|
|
246
|
+
if (present.some((n) => ATTRIBUTION_HEADER_NAMES.has(n))) return { headers: {}, none: 'user-set' };
|
|
247
|
+
const headers = { 'HTTP-Referer': `https://passioncode.ai/${setting.product}/`, 'X-OpenRouter-Title': setting.title };
|
|
248
|
+
if (setting.categories?.length) headers['X-OpenRouter-Categories'] = setting.categories.join(',');
|
|
249
|
+
return { headers };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
|
|
254
|
+
* calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
|
|
255
|
+
* disagree; a per-order limit (`per_job`, `approval`) gets `spentUsd: null`; every other limit states its own spend.
|
|
256
|
+
* A report whose limits break FAC-SEM-031 is refused (`usageLimitProblems`).
|
|
257
|
+
*/
|
|
258
|
+
export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget, budgets } = {}) {
|
|
259
|
+
receipts = Array.from(receipts); // read twice: the days, then a rolling window
|
|
260
|
+
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
|
|
261
|
+
const days = new Map();
|
|
262
|
+
for (const r of receipts) {
|
|
263
|
+
const date = String(r.at ?? '').slice(0, 10);
|
|
264
|
+
if (date < first || date > last) continue;
|
|
265
|
+
const rows = days.get(date) ?? new Map();
|
|
266
|
+
days.set(date, rows);
|
|
267
|
+
const k = `${r.provider}\u0000${r.model}\u0000${r.route ?? ''}`;
|
|
268
|
+
// DEC-0046: rows of one model on two routes stay apart.
|
|
269
|
+
const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, ...(r.route ? { route: r.route } : {}), bases: new Set() });
|
|
270
|
+
rows.set(k, row);
|
|
271
|
+
row.calls += 1;
|
|
272
|
+
for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
|
|
273
|
+
if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd;
|
|
274
|
+
if (r.tokensMeasured === false) row.unmeasuredCalls = (row.unmeasuredCalls ?? 0) + 1; // absent is 0 (DEC-0091)
|
|
275
|
+
row.bases.add(r.costBasis ?? 'unknown');
|
|
276
|
+
}
|
|
277
|
+
const outDays = [...days.keys()].sort().map((date) => {
|
|
278
|
+
const models = [...days.get(date).values()]
|
|
279
|
+
.map(({ bases, ...row }) => closeRow({ ...row, costBasis: bases.size === 1 ? [...bases][0] : 'mixed' }))
|
|
280
|
+
.sort((a, b) => (`${a.provider}\u0000${a.model}\u0000${a.route ?? ''}` < `${b.provider}\u0000${b.model}\u0000${b.route ?? ''}` ? -1 : 1));
|
|
281
|
+
const day = emptyRow({ date });
|
|
282
|
+
for (const m of models) {
|
|
283
|
+
for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
|
|
284
|
+
if (m.unmeasuredCalls) day.unmeasuredCalls = (day.unmeasuredCalls ?? 0) + m.unmeasuredCalls;
|
|
285
|
+
day.cost += m.costUsd ?? 0;
|
|
286
|
+
}
|
|
287
|
+
return { ...closeRow(day), byModel: models };
|
|
288
|
+
});
|
|
289
|
+
const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
|
|
290
|
+
const projects = byProject(receipts, first, last);
|
|
291
|
+
if (projects.length) report.byProject = projects;
|
|
292
|
+
if (budget !== undefined) {
|
|
293
|
+
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
294
|
+
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
|
|
295
|
+
}
|
|
296
|
+
if (budgets !== undefined) {
|
|
297
|
+
report.budgets = budgets.map((given) => {
|
|
298
|
+
if (!isPlainObject(given)) throw new ServiceError('Each limit in budgets is an object.');
|
|
299
|
+
const limit = { ...given };
|
|
300
|
+
if (!('spentUsd' in limit)) {
|
|
301
|
+
if (PER_ORDER_KINDS.includes(limit.kind)) limit.spentUsd = null;
|
|
302
|
+
else if (limit.scope === 'machine' && ['day', 'month'].includes(limit.period)) limit.spentUsd = spentIn(outDays, limit.period, last);
|
|
303
|
+
else if (limit.scope === 'machine' && isNumber(limit.windowSeconds)) limit.spentUsd = spentSince(receipts, now - limit.windowSeconds * 1000, now);
|
|
304
|
+
}
|
|
305
|
+
return limit;
|
|
306
|
+
});
|
|
307
|
+
const problems = usageLimitProblems(report);
|
|
308
|
+
if (problems.length) throw new ServiceError(`The report's limits are not coherent: ${problems.join('; ')}`);
|
|
309
|
+
}
|
|
310
|
+
return report;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Spend in a rolling window, from the receipts' own times; null when every call in it is unpriced. */
|
|
314
|
+
function spentSince(receipts, start, end) {
|
|
315
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
316
|
+
const lo = iso(start), hi = iso(end);
|
|
317
|
+
const inside = [...receipts].filter((r) => String(r.at ?? '') > lo && String(r.at ?? '') <= hi);
|
|
318
|
+
if (inside.length && inside.every((r) => r.costUsd === null || r.costUsd === undefined)) return null;
|
|
319
|
+
return Math.round(inside.reduce((n, r) => n + (r.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Spend in the current UTC day or month; null when every call in it is unpriced. */
|
|
323
|
+
function spentIn(outDays, period, last) {
|
|
324
|
+
const prefix = period === 'day' ? last : last.slice(0, 7);
|
|
325
|
+
const window = outDays.filter((d) => d.date.startsWith(prefix));
|
|
326
|
+
const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
|
|
327
|
+
return unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const LIMIT_SCOPES = Object.freeze(['machine', 'project', 'pool', 'job']);
|
|
331
|
+
|
|
332
|
+
export const PER_ORDER_KINDS = Object.freeze(['per_job', 'approval']);
|
|
333
|
+
|
|
334
|
+
const LIMIT_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
|
|
335
|
+
|
|
336
|
+
const LIMIT_KIND = /^[a-z][a-z0-9_]{0,31}$/;
|
|
337
|
+
|
|
338
|
+
const LIMIT_FIELDS = ['id', 'scope', 'subject', 'kind', 'period', 'windowSeconds', 'since', 'limitUsd', 'spentUsd', 'enforced', 'tripped'];
|
|
339
|
+
|
|
340
|
+
const TIMESTAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
|
|
341
|
+
|
|
342
|
+
/** A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset. */
|
|
343
|
+
export function isTimestamp(value) {
|
|
344
|
+
const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
|
|
345
|
+
if (!m) return false;
|
|
346
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
347
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
348
|
+
const calendar = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
349
|
+
const offset = m[9] === undefined || (Number(m[9]) <= 23 && Number(m[10]) <= 59);
|
|
350
|
+
return calendar && hour <= 23 && minute <= 59 && second <= 60 && offset;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
354
|
+
|
|
355
|
+
export const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031 — the twin of
|
|
359
|
+
* fabric_service.usage_limit_problems. A breach is not a problem, and an unknown kind is never refused.
|
|
360
|
+
*/
|
|
361
|
+
export function usageLimitProblems(report) {
|
|
362
|
+
const problems = [];
|
|
363
|
+
const limits = report?.budgets;
|
|
364
|
+
if (limits === undefined) return problems;
|
|
365
|
+
if (!Array.isArray(limits) || limits.length > 64) return ['budgets is a list of at most 64 limits'];
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
limits.forEach((limit, i) => {
|
|
368
|
+
const at = `/budgets/${i}`;
|
|
369
|
+
if (!isPlainObject(limit)) { problems.push(`${at} is not an object`); return; }
|
|
370
|
+
for (const key of Object.keys(limit)) if (!LIMIT_FIELDS.includes(key)) problems.push(`${at}/${key} is not a limit field`);
|
|
371
|
+
for (const key of ['id', 'scope', 'kind', 'limitUsd', 'spentUsd', 'enforced']) if (!(key in limit)) problems.push(`${at} is missing ${key}`);
|
|
372
|
+
if ('id' in limit && !(typeof limit.id === 'string' && LIMIT_ID.test(limit.id))) problems.push(`${at}/id must match ${LIMIT_ID.source}`);
|
|
373
|
+
if ('scope' in limit && !LIMIT_SCOPES.includes(limit.scope)) problems.push(`${at}/scope must be one of ${LIMIT_SCOPES.join(', ')}`);
|
|
374
|
+
if ('subject' in limit && !(typeof limit.subject === 'string' && limit.subject.length >= 1 && limit.subject.length <= 128)) problems.push(`${at}/subject is 1 to 128 characters`);
|
|
375
|
+
if ('kind' in limit && !(typeof limit.kind === 'string' && LIMIT_KIND.test(limit.kind))) problems.push(`${at}/kind must match ${LIMIT_KIND.source}`);
|
|
376
|
+
if ('period' in limit && !['day', 'month'].includes(limit.period)) problems.push(`${at}/period is day or month`);
|
|
377
|
+
if ('windowSeconds' in limit && !(Number.isInteger(limit.windowSeconds) && limit.windowSeconds >= 1)) problems.push(`${at}/windowSeconds is a whole number of seconds, at least 1`);
|
|
378
|
+
if ('since' in limit && !isTimestamp(limit.since)) problems.push(`${at}/since is a UTC timestamp`);
|
|
379
|
+
if ('limitUsd' in limit && !(limit.limitUsd === null || (isNumber(limit.limitUsd) && limit.limitUsd > 0))) problems.push(`${at}/limitUsd is a dollar amount above zero, or null`);
|
|
380
|
+
if ('spentUsd' in limit && !(limit.spentUsd === null || (isNumber(limit.spentUsd) && limit.spentUsd >= 0))) problems.push(`${at}/spentUsd is a dollar amount, zero or more, or null`);
|
|
381
|
+
for (const key of ['enforced', 'tripped']) if (key in limit && typeof limit[key] !== 'boolean') problems.push(`${at}/${key} is true or false`);
|
|
382
|
+
|
|
383
|
+
const flag = (where, message) => problems.push(`FAC-SEM-031 ${at}/${where}: ${message}`);
|
|
384
|
+
const id = String(limit.id ?? '');
|
|
385
|
+
if (seen.has(id)) flag('id', `limit id ${id} appears twice`);
|
|
386
|
+
seen.add(id);
|
|
387
|
+
const named = limit.scope === 'project' || limit.scope === 'pool';
|
|
388
|
+
if (named && !('subject' in limit)) flag('subject', `a ${limit.scope} limit names its ${limit.scope} in subject`);
|
|
389
|
+
if (!named && 'subject' in limit) flag('subject', `a ${limit.scope} limit has no subject`);
|
|
390
|
+
const windows = ['period', 'windowSeconds', 'since'].filter((f) => f in limit);
|
|
391
|
+
if (windows.length > 1) flag(windows[1], `a limit has one window, not ${windows.join(' and ')}`);
|
|
392
|
+
const kind = String(limit.kind ?? '');
|
|
393
|
+
const seconds = isNumber(limit.windowSeconds) ? limit.windowSeconds : null;
|
|
394
|
+
if (PER_ORDER_KINDS.includes(kind)) {
|
|
395
|
+
if (windows.length) flag(windows[0], `${kind} bounds one order and has no window`);
|
|
396
|
+
if (limit.spentUsd !== null) flag('spentUsd', `${kind} bounds one order, so nothing is spent against it (null)`);
|
|
397
|
+
} else if (kind === 'daily') {
|
|
398
|
+
if (!(limit.period === 'day' || seconds === 86400)) flag('period', 'a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)');
|
|
399
|
+
} else if (kind === 'monthly') {
|
|
400
|
+
if (!(limit.period === 'month' || (seconds !== null && seconds >= 28 * 86400 && seconds <= 31 * 86400))) flag('period', 'a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)');
|
|
401
|
+
} else if (kind === 'velocity') {
|
|
402
|
+
if (seconds === null) flag('windowSeconds', 'a velocity limit counts a rolling window (windowSeconds)');
|
|
403
|
+
} else if (kind === 'pool' || kind === 'emergency') {
|
|
404
|
+
if (!windows.length) flag('period', `a ${kind} limit names the window it counts (period, windowSeconds or since)`);
|
|
405
|
+
}
|
|
406
|
+
const known = PER_ORDER_KINDS.includes(kind) || ['daily', 'monthly', 'pool', 'emergency'].includes(kind);
|
|
407
|
+
if (known && 'limitUsd' in limit && limit.limitUsd === null) flag('limitUsd', `a ${kind} limit is a dollar amount; only a relative limit (velocity) may leave it null`);
|
|
408
|
+
if (limit.tripped === true && limit.enforced !== true) flag('tripped', 'a limit that is not enforced cannot have stopped work');
|
|
409
|
+
});
|
|
410
|
+
const budget = report.budget;
|
|
411
|
+
if (isPlainObject(budget) && limits.length) {
|
|
412
|
+
const kind = budget.period === 'day' ? 'daily' : 'monthly';
|
|
413
|
+
const same = (x, y) => (x === null || y === null ? x === y : isNumber(x) && isNumber(y) && Math.abs(x - y) <= 0.000001);
|
|
414
|
+
const twin = limits.some((l) => isPlainObject(l) && l.scope === 'machine' && l.enforced === true && l.kind === kind && l.period === budget.period && same(l.limitUsd, budget.limitUsd) && same(l.spentUsd, budget.spentUsd));
|
|
415
|
+
if (!twin) problems.push(`FAC-SEM-031 /budget: budget repeats one enforced machine ${kind} limit with period ${budget.period} in budgets[], so old and new readers agree`);
|
|
416
|
+
}
|
|
417
|
+
return problems;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
|
|
421
|
+
* One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
|
|
422
|
+
export class JsonlUsageLedger {
|
|
423
|
+
constructor(file) { this.file = file; }
|
|
424
|
+
|
|
425
|
+
record(receipt) {
|
|
426
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
427
|
+
const fd = fs.openSync(this.file, 'a+', 0o600);
|
|
428
|
+
try {
|
|
429
|
+
const { size } = fs.fstatSync(fd);
|
|
430
|
+
const last = Buffer.alloc(1);
|
|
431
|
+
// A killed writer left a torn line: never glue a receipt to it.
|
|
432
|
+
const lead = size && fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a ? '\n' : '';
|
|
433
|
+
fs.writeSync(fd, lead + JSON.stringify(receipt) + '\n');
|
|
434
|
+
} finally {
|
|
435
|
+
fs.closeSync(fd);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
receipts() {
|
|
440
|
+
if (!fs.existsSync(this.file)) return [];
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const line of fs.readFileSync(this.file, 'utf8').split('\n')) {
|
|
443
|
+
if (!line) continue;
|
|
444
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn last line from a killed writer is skipped */ }
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
prune(now = Date.now()) {
|
|
450
|
+
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000);
|
|
451
|
+
const all = this.receipts();
|
|
452
|
+
const kept = all.filter((r) => String(r.at ?? '').slice(0, 10) >= first);
|
|
453
|
+
if (kept.length !== all.length) atomicWrite(this.file, kept.map((r) => JSON.stringify(r) + '\n').join(''));
|
|
454
|
+
return all.length - kept.length;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
report(options) { return usageReport(this.receipts(), options); }
|
|
458
|
+
}
|