segreant 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- package/pricing/models.json +1366 -60
package/dist/demo/seed.js
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
* writer. The demo therefore exercises the production value pipeline, not a
|
|
22
22
|
* parallel fake; only the inputs are synthetic, and every surface stays DEMO.
|
|
23
23
|
*/
|
|
24
|
-
import { computeCost, syntheticPricingEvidence, unpricedPricingEvidence } from '../cost/pricing.js';
|
|
24
|
+
import { computeCost, withoutDeclaredPrices, syntheticPricingEvidence, unpricedPricingEvidence, notSentPricingEvidence } from '../cost/pricing.js';
|
|
25
25
|
import { startOfLocalDay } from '../budget/guard.js';
|
|
26
26
|
import { GATE_LADDER, gateResultFromVerdict, scoreFunnel } from '../value/gates.js';
|
|
27
27
|
import { classifyTaskType } from '../value/taskType.js';
|
|
28
28
|
import { boundedLift } from '../value/lift.js';
|
|
29
|
+
import { parseBillingImportText } from '../billing/importer.js';
|
|
30
|
+
import { isOnDeclaredRoute } from '../billing/reconcile.js';
|
|
29
31
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
32
|
const HOUR_MS = 60 * 60 * 1000;
|
|
31
33
|
/**
|
|
@@ -107,7 +109,7 @@ function addRequest(ctx, spec) {
|
|
|
107
109
|
cacheWriteTokens,
|
|
108
110
|
cacheReadTokens,
|
|
109
111
|
});
|
|
110
|
-
const cost = calculated ?? { costUsd: 0, estimated: false, pricing:
|
|
112
|
+
const cost = calculated ?? { costUsd: 0, estimated: false, pricing: notSentPricingEvidence() };
|
|
111
113
|
// The route dictates every field that would have to agree in a real row, so a
|
|
112
114
|
// seeded row cannot depict a combination the product could never produce.
|
|
113
115
|
const route = ROUTES[spec.route ?? 'proxy_declared'];
|
|
@@ -141,6 +143,11 @@ function addRequest(ctx, spec) {
|
|
|
141
143
|
durationMs: blocked ? 2 : int(ctx.rng, 600, 9000),
|
|
142
144
|
user: spec.user,
|
|
143
145
|
source: route.source ?? spec.source ?? null,
|
|
146
|
+
// Proxied OpenAI traffic is captured on the declared route, as a real proxy
|
|
147
|
+
// marks it, so the sample bill has local rows to be compared against.
|
|
148
|
+
...(ctx.openAiScopeId !== null && spec.model.provider === 'openai' && route.via === 'proxy'
|
|
149
|
+
? { scopeCaptureStatus: 'declared_unverified', providerScopeDeclarationId: ctx.openAiScopeId }
|
|
150
|
+
: {}),
|
|
144
151
|
};
|
|
145
152
|
ctx.store.insertRequest(row);
|
|
146
153
|
ctx.requests += 1;
|
|
@@ -190,18 +197,20 @@ function addProposal(ctx, idx, sessionId, project, tsEpochMs) {
|
|
|
190
197
|
* enrich the spend + acceptance surfaces; the realized-value picture is seeded
|
|
191
198
|
* separately as persisted work units (see seedRealizationUnits).
|
|
192
199
|
*/
|
|
193
|
-
function codingSession(ctx, idx,
|
|
200
|
+
function codingSession(ctx, idx, made) {
|
|
194
201
|
const sessionId = `demo-sess-code-${idx}`;
|
|
195
|
-
|
|
202
|
+
pick(ctx.rng, PROJECTS); // drawn and set aside: the commit names the project, and every later draw stays where it was
|
|
203
|
+
const project = made.project;
|
|
196
204
|
const user = pick(ctx.rng, NAMED_USERS);
|
|
197
205
|
const source = pick(ctx.rng, ['opencode', 'cursor']);
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
// Drawn in the original order, then laid out so the session ends a few
|
|
207
|
+
// minutes before the commit it made, as a real session's log would show.
|
|
200
208
|
const calls = int(ctx.rng, 6, 12);
|
|
201
|
-
|
|
209
|
+
const specs = [];
|
|
210
|
+
let offset = 0;
|
|
202
211
|
for (let i = 0; i < calls; i++) {
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
specs.push({
|
|
213
|
+
offsetMs: offset,
|
|
205
214
|
model: pick(ctx.rng, [OPUS, SONNET, SONNET]),
|
|
206
215
|
project,
|
|
207
216
|
user,
|
|
@@ -212,9 +221,24 @@ function codingSession(ctx, idx, startMs) {
|
|
|
212
221
|
cacheWriteTokens: i === 0 ? int(ctx.rng, 8_000, 30_000) : 0,
|
|
213
222
|
cacheReadTokens: i === 0 ? 0 : int(ctx.rng, 10_000, 80_000),
|
|
214
223
|
});
|
|
215
|
-
|
|
224
|
+
offset += int(ctx.rng, 2 * 60 * 1000, 25 * 60 * 1000);
|
|
216
225
|
}
|
|
226
|
+
// A session's cached context only grows as the conversation does, so the
|
|
227
|
+
// cache reads already drawn are handed out in rising order.
|
|
228
|
+
const reads = specs.slice(1).map((r) => r.cacheReadTokens ?? 0).sort((a, b) => a - b);
|
|
229
|
+
specs.slice(1).forEach((r, i) => { r.cacheReadTokens = reads[i]; });
|
|
230
|
+
const lastOffset = specs[specs.length - 1].offsetMs;
|
|
231
|
+
const startMs = made.tsEpochMs - 3 * 60 * 1000 - lastOffset;
|
|
232
|
+
ctx.store.upsertSession(sessionId, project, source, startMs);
|
|
233
|
+
ctx.sessions += 1;
|
|
234
|
+
for (const { offsetMs, ...spec } of specs)
|
|
235
|
+
addRequest(ctx, { ...spec, tsEpochMs: startMs + offsetMs });
|
|
217
236
|
addProposal(ctx, idx, sessionId, project, startMs + 1000);
|
|
237
|
+
// The commit as the session's own log reports it: the only link the
|
|
238
|
+
// Workbench draws between spend and a commit.
|
|
239
|
+
ctx.store.recordObservedCommit({
|
|
240
|
+
source, sessionId, shortSha: made.hash, branch: 'main', subject: made.subject, tsEpochMs: made.tsEpochMs, cwd: null,
|
|
241
|
+
});
|
|
218
242
|
}
|
|
219
243
|
/**
|
|
220
244
|
* A non-coding session (chat / research / drafting): a short burst with no
|
|
@@ -319,6 +343,98 @@ const REALIZATION_ROSTER = [
|
|
|
319
343
|
{ project: 'backend-api', model: 'claude-opus-4-8', subject: 'feat: per-context frontier', daysAgo: 5, costUsd: 1.95, linesAdded: 175, acceptance: 0.83, arch: 'maturing' },
|
|
320
344
|
{ project: 'data-pipeline', model: 'gpt-4o', subject: 'refactor: store-backed realization', daysAgo: 3, costUsd: 0.8, linesAdded: 120, acceptance: 0.60, arch: 'maturing' },
|
|
321
345
|
];
|
|
346
|
+
const DEMO_OPENAI_PROJECT = 'proj_demo_sample';
|
|
347
|
+
/**
|
|
348
|
+
* A provider bill for the sample's OpenAI route, imported, adopted and compared
|
|
349
|
+
* through the same store calls as `segreant billing import`, `openai-costs
|
|
350
|
+
* adopt` and `reconcile`. The bill is built from the route's own daily list
|
|
351
|
+
* cost, with most days billed a few percent above it: the gap a real
|
|
352
|
+
* comparison finds when some usage (a playground, a teammate's key) never
|
|
353
|
+
* passed through Segreant. It is synthetic and filed as an operator export.
|
|
354
|
+
*/
|
|
355
|
+
function seedBillComparison(ctx, now, days) {
|
|
356
|
+
const scopeId = ctx.openAiScopeId;
|
|
357
|
+
if (scopeId === null)
|
|
358
|
+
return;
|
|
359
|
+
// Whole UTC days ending at least 48 hours ago: the comparison refuses a
|
|
360
|
+
// period the provider may still be adding charges to, as it should.
|
|
361
|
+
const end = Math.floor((now - 2 * DAY_MS) / DAY_MS) * DAY_MS;
|
|
362
|
+
const start = end - (days - 1) * DAY_MS;
|
|
363
|
+
const perDay = new Map();
|
|
364
|
+
for (const r of ctx.store.requestsInRange(start, end)) {
|
|
365
|
+
if (!isOnDeclaredRoute(r, scopeId))
|
|
366
|
+
continue;
|
|
367
|
+
const day = Math.floor(r.tsEpochMs / DAY_MS) * DAY_MS;
|
|
368
|
+
perDay.set(day, (perDay.get(day) ?? 0) + Math.round(r.costUsd * 1e6));
|
|
369
|
+
}
|
|
370
|
+
const records = [];
|
|
371
|
+
for (let day = start; day < end; day += DAY_MS) {
|
|
372
|
+
const local = perDay.get(day) ?? 0;
|
|
373
|
+
if (local === 0)
|
|
374
|
+
continue;
|
|
375
|
+
const uplift = ctx.rng() < 0.7 ? 1 + int(ctx.rng, 1, 7) / 100 : 1;
|
|
376
|
+
const micros = Math.round(local * uplift);
|
|
377
|
+
records.push({
|
|
378
|
+
sourceRecordId: `demo-line-${day}`,
|
|
379
|
+
chargePeriodStart: new Date(day).toISOString(),
|
|
380
|
+
chargePeriodEnd: new Date(day + DAY_MS).toISOString(),
|
|
381
|
+
observedAt: new Date(day + DAY_MS).toISOString(),
|
|
382
|
+
providerProjectRef: DEMO_OPENAI_PROJECT,
|
|
383
|
+
chargeType: 'usage', currency: 'USD', amount: (micros / 1e6).toFixed(6), sku: 'model-usage',
|
|
384
|
+
service: 'api', model: 'mixed', region: null, usageUnit: 'tokens', usageQuantity: '0',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (records.length === 0)
|
|
388
|
+
return;
|
|
389
|
+
const file = Buffer.from(JSON.stringify({
|
|
390
|
+
schemaVersion: 1,
|
|
391
|
+
source: {
|
|
392
|
+
system: 'operator-export', provider: 'openai', exportId: `demo-export-${start}`,
|
|
393
|
+
billingAccountRef: 'org_demo_sample', exportedAt: new Date(now).toISOString(),
|
|
394
|
+
periodStart: new Date(start).toISOString(), periodEnd: new Date(end).toISOString(), coverage: 'complete',
|
|
395
|
+
},
|
|
396
|
+
records,
|
|
397
|
+
}), 'utf8');
|
|
398
|
+
const imported = ctx.store.applyBillingImport(parseBillingImportText(file, 'openai-costs-sample.segreant.json').input, now);
|
|
399
|
+
ctx.store.issueBillingImportToKernel(imported.run.importId);
|
|
400
|
+
const plan = ctx.store.planOpenAiCostsAdoption({ importId: imported.run.importId, declaredScopeId: scopeId, providerProjectRef: DEMO_OPENAI_PROJECT });
|
|
401
|
+
if (!plan.adoptable)
|
|
402
|
+
throw new Error(`demo seed: the sample bill could not be adopted (${plan.refusal})`);
|
|
403
|
+
ctx.store.adoptOpenAiCostsFromImport(plan, now);
|
|
404
|
+
const result = ctx.store.reconcileOpenAiCosts({ now });
|
|
405
|
+
if (!result || result.status === 'refused')
|
|
406
|
+
throw new Error(`demo seed: the sample bill could not be compared (${result ? `${result.refusal}: ${result.detail}` : 'not reconcilable'})`);
|
|
407
|
+
const runId = ctx.store.saveReconciliationRun(result);
|
|
408
|
+
if (result.coverage.providerDays > 0)
|
|
409
|
+
ctx.store.issueOpenAiReconciliationToKernel(runId);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Three cost centres, one direct rule per project, and one recorded split of
|
|
413
|
+
* the fortnight, through the same calls as `segreant alloc`. Traffic that
|
|
414
|
+
* never declared a project matches no rule and stays unallocated, which is
|
|
415
|
+
* the gap the Allocate page exists to show.
|
|
416
|
+
*/
|
|
417
|
+
function seedCostCentres(ctx, now, days) {
|
|
418
|
+
const centres = [
|
|
419
|
+
['platform', 'Platform', 'carol@team', 'backend-api'],
|
|
420
|
+
['product', 'Product', 'alice@team', 'web-frontend'],
|
|
421
|
+
['data', 'Data', 'bob@team', 'data-pipeline'],
|
|
422
|
+
];
|
|
423
|
+
const at = now - days * DAY_MS;
|
|
424
|
+
for (const [id, name, owner, project] of centres) {
|
|
425
|
+
ctx.store.upsertCostCentre({ costCentreId: id, name, owner, createdAtMs: at });
|
|
426
|
+
ctx.store.saveAllocationRule({
|
|
427
|
+
ruleId: `${id}-direct`, method: 'direct', match: { project }, targets: [{ costCentreId: id, ratio: 1 }],
|
|
428
|
+
priority: 100, effectiveFromMs: 0, effectiveToMs: null, revokedAtMs: null, owner, note: `All ${project} spend`, createdAtMs: at,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const result = ctx.store.allocatePeriod(now - days * DAY_MS, now, now);
|
|
432
|
+
if (!result.conserves)
|
|
433
|
+
throw new Error('demo seed: the sample split did not conserve its input');
|
|
434
|
+
ctx.store.saveAllocationRun(result, now);
|
|
435
|
+
}
|
|
436
|
+
/** The roster commits the sample's coding sessions made, by subject. */
|
|
437
|
+
const CODING_COMMITS = ['feat: alerts banner rework', 'fix: dark-mode contrast', 'fix: chart axis labels', 'feat: per-context frontier'];
|
|
322
438
|
function makeRealizationUnit(ctx, now, spec, hash) {
|
|
323
439
|
const arch = ARCHETYPES[spec.arch];
|
|
324
440
|
const tsEpochMs = now - Math.floor(spec.daysAgo * DAY_MS);
|
|
@@ -347,6 +463,9 @@ function makeRealizationUnit(ctx, now, spec, hash) {
|
|
|
347
463
|
costPerHundredLines: spec.linesAdded > 0 ? (spec.costUsd / spec.linesAdded) * 100 : null,
|
|
348
464
|
ageDays: spec.daysAgo,
|
|
349
465
|
maturing: arch.maturing,
|
|
466
|
+
// The sample is synthesised with the default window; saying so keeps it
|
|
467
|
+
// from reading as an old measurement that never recorded its own.
|
|
468
|
+
checkWindowDays: 14,
|
|
350
469
|
survivalRatio: arch.survival,
|
|
351
470
|
reverted: arch.verdicts.clean === 'fail',
|
|
352
471
|
hadProposal: spec.acceptance !== null,
|
|
@@ -485,9 +604,20 @@ function seedImportedTail(ctx, now, days) {
|
|
|
485
604
|
* the deterministic ids collision-free).
|
|
486
605
|
*/
|
|
487
606
|
export function seedDemo(store, opts = {}) {
|
|
607
|
+
// Sample data is priced from the card alone, whatever the user declared.
|
|
608
|
+
return withoutDeclaredPrices(() => seedDemoFromCard(store, opts));
|
|
609
|
+
}
|
|
610
|
+
function seedDemoFromCard(store, opts) {
|
|
488
611
|
const now = opts.now ?? Date.now();
|
|
489
612
|
const days = opts.days ?? 14;
|
|
490
|
-
const ctx = { store, rng: mulberry32(0xae915f10), n: 0, requests: 0, blocked: 0, sessions: 0, proposals: 0, signals: 0, cost: 0 };
|
|
613
|
+
const ctx = { store, rng: mulberry32(0xae915f10), n: 0, requests: 0, blocked: 0, sessions: 0, proposals: 0, signals: 0, cost: 0, openAiScopeId: null };
|
|
614
|
+
// Declared before any traffic, so the whole fortnight's proxied OpenAI rows
|
|
615
|
+
// fall on the route. No random draw is taken here: every later figure stays put.
|
|
616
|
+
const declaredAtMs = now - (days + 1) * DAY_MS;
|
|
617
|
+
ctx.openAiScopeId = store.setOpenAiScope({
|
|
618
|
+
billingAccountRef: 'org_demo_sample', providerProjectRef: DEMO_OPENAI_PROJECT,
|
|
619
|
+
upstreamBase: 'https://api.openai.com', declaredAtMs, activatedAtMs: declaredAtMs,
|
|
620
|
+
}).declarationId;
|
|
491
621
|
// Each prior day of the fortnight: light background chatter plus a real block
|
|
492
622
|
// of opus/sonnet work. This establishes a believable active-day baseline (a
|
|
493
623
|
// few dollars a day) so today's runaway reads as a credible multiple of a
|
|
@@ -519,9 +649,15 @@ export function seedDemo(store, opts = {}) {
|
|
|
519
649
|
});
|
|
520
650
|
}
|
|
521
651
|
}
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
652
|
+
// Four work sessions, each ending in a commit from the measured roster: one
|
|
653
|
+
// kept, one not kept, two still too new to judge.
|
|
654
|
+
CODING_COMMITS.forEach((subject, i) => {
|
|
655
|
+
const at = REALIZATION_ROSTER.findIndex((u) => u.subject === subject);
|
|
656
|
+
const spec = REALIZATION_ROSTER[at];
|
|
657
|
+
codingSession(ctx, i + 1, {
|
|
658
|
+
hash: `demo-commit-${at + 1}`, subject, project: spec.project, tsEpochMs: now - Math.floor(spec.daysAgo * DAY_MS),
|
|
659
|
+
});
|
|
660
|
+
});
|
|
525
661
|
// Non-coding sessions, seeded per-user so the per-user VALUE distribution is
|
|
526
662
|
// real: a spread of extraction across the team (some extract nearly all their
|
|
527
663
|
// spend, some little), which drives dispersion + coaching headroom in
|
|
@@ -589,6 +725,9 @@ export function seedDemo(store, opts = {}) {
|
|
|
589
725
|
}
|
|
590
726
|
seedImportedTail(ctx, now, days);
|
|
591
727
|
const realizationUnits = seedRealizationUnits(ctx, now);
|
|
728
|
+
// Last, so their random draws come after every other figure's.
|
|
729
|
+
seedBillComparison(ctx, now, days);
|
|
730
|
+
seedCostCentres(ctx, now, days);
|
|
592
731
|
return {
|
|
593
732
|
requests: ctx.requests,
|
|
594
733
|
blocked: ctx.blocked,
|
package/dist/economics/ledger.js
CHANGED
|
@@ -32,7 +32,7 @@ function canonicalBoundary(value) {
|
|
|
32
32
|
// Verification (parse, canonicalize, digest, identity) is a pure function of
|
|
33
33
|
// the stored row's bytes and yields a deeply frozen event, so a row whose every
|
|
34
34
|
// stored column is unchanged reuses it; any byte difference re-verifies.
|
|
35
|
-
const VERIFIED_EVENT_CACHE_MAX =
|
|
35
|
+
const VERIFIED_EVENT_CACHE_MAX = 150_000;
|
|
36
36
|
const verifiedEvents = new Map();
|
|
37
37
|
function sameStoredRow(a, b) {
|
|
38
38
|
return a.event_json === b.event_json && a.event_digest === b.event_digest && a.event_kind === b.event_kind
|
package/dist/egress/policy.js
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, exact policy for Segreant-process HTTP(S) egress. Socket creation lives
|
|
3
|
+
* only in transport.ts, so a feature cannot silently make its own network path.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
1
6
|
import { EGRESS_DATA_CLASSES, EGRESS_PURPOSES, canonicalAuthorizationPath, canonicalOrigin, normalizePathPrefix, validateEgressRule, } from './ruleValidation.js';
|
|
2
7
|
export { EGRESS_DATA_CLASSES, EGRESS_PURPOSES, validateEgressRule };
|
|
8
|
+
/**
|
|
9
|
+
* Segreant's own self-maintenance downloads: fixed public files, fetched with a
|
|
10
|
+
* plain GET that carries nothing about the user. With the providers' official
|
|
11
|
+
* APIs (below), they are the built-in exceptions to
|
|
12
|
+
* local_locked, because a rate card that can never update prices every new
|
|
13
|
+
* model by a guess (claude-haiku-5-5 at $3/$15 against a $0.10/$0.50 list
|
|
14
|
+
* price). The match is exact: purpose, data class, GET, and the full URL with no
|
|
15
|
+
* query or fragment, so no caller can reach any other address through it. The
|
|
16
|
+
* DNS answer must still be public and every attempt is in the receipt chain.
|
|
17
|
+
* Like the providers' official APIs it holds in every mode, controlled_cloud
|
|
18
|
+
* included (PR #148). `segreant pricing --auto off` stops Segreant
|
|
19
|
+
* asking on its own; a manual `segreant pricing --refresh` is the user asking.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_PRICE_LIST_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
22
|
+
export const MAINTENANCE_RULE_ID = 'segreant-builtin-price-list';
|
|
23
|
+
const MAINTENANCE_TARGETS = [
|
|
24
|
+
{ purpose: 'pricing_refresh', dataClass: 'pricing_manifest', href: DEFAULT_PRICE_LIST_URL },
|
|
25
|
+
];
|
|
26
|
+
function maintenanceTarget(target, intent, method) {
|
|
27
|
+
if (method !== 'GET' || target.protocol !== 'https:' || target.search !== '' || target.hash !== '')
|
|
28
|
+
return false;
|
|
29
|
+
return MAINTENANCE_TARGETS.some((m) => m.purpose === intent.purpose && m.dataClass === intent.dataClass && m.href === target.href);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The providers' own APIs. A request the user's tool sends to its provider is
|
|
33
|
+
* the user's traffic, already going there before Segreant existed; Segreant only
|
|
34
|
+
* meters it on the way through. Locking it by default made a fresh install
|
|
35
|
+
* refuse every request with a 403 until two egress commands were run, which
|
|
36
|
+
* protected nothing (the owner's call, 2026-10-18). What stays locked is
|
|
37
|
+
* everything that would send Segreant's own data somewhere new: webhooks, team
|
|
38
|
+
* rollups, hosted judging. The match is exact: provider purpose and data class,
|
|
39
|
+
* GET or POST, HTTPS, the official origin, under /v1/. A custom upstream (a
|
|
40
|
+
* gateway, a self-hosted model) is the user's to allow with its own rule.
|
|
41
|
+
*/
|
|
42
|
+
export const PROVIDER_RULE_ID_PREFIX = 'segreant-builtin-provider-';
|
|
43
|
+
const PROVIDER_TARGETS = [
|
|
44
|
+
{ id: 'anthropic', origin: 'https://api.anthropic.com', pathPrefix: '/v1/' },
|
|
45
|
+
{ id: 'openai', origin: 'https://api.openai.com', pathPrefix: '/v1/' },
|
|
46
|
+
];
|
|
47
|
+
function providerTarget(target, intent, method) {
|
|
48
|
+
if (intent.purpose !== 'provider_inference' || intent.dataClass !== 'provider_request')
|
|
49
|
+
return null;
|
|
50
|
+
if ((method !== 'POST' && method !== 'GET') || target.protocol !== 'https:')
|
|
51
|
+
return null;
|
|
52
|
+
const match = PROVIDER_TARGETS.find((p) => p.origin === target.origin && pathMatchesPrefix(target.pathname, p.pathPrefix));
|
|
53
|
+
return match ? PROVIDER_RULE_ID_PREFIX + match.id : null;
|
|
54
|
+
}
|
|
55
|
+
/** Whether requests to this upstream go out with no rule: it is one of the providers' official APIs. */
|
|
56
|
+
export function isBuiltInProviderUpstream(url) {
|
|
57
|
+
try {
|
|
58
|
+
const target = new URL(url);
|
|
59
|
+
return PROVIDER_TARGETS.some((p) => p.origin === target.origin);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A rule id the egress command accepts (a lowercase letter, then 2-63 of
|
|
67
|
+
* a-z, 0-9, _ or -). The hostname alone was printed before, and its dots made
|
|
68
|
+
* the printed command fail with "Rule refused" (PR #148 review).
|
|
69
|
+
*/
|
|
70
|
+
export function providerRuleId(origin, method = 'POST') {
|
|
71
|
+
// Rules are replaced by id, so the id must be unique per origin and method:
|
|
72
|
+
// the method keeps a GET and a POST rule for one host apart, and a short hash
|
|
73
|
+
// of the full canonical origin (scheme, host and port) keeps apart hosts whose
|
|
74
|
+
// slugs collide (api.foo-bar.com / api.foo.bar.com), share a truncated
|
|
75
|
+
// prefix, or differ only by port (PR #148 review). The slug is the hostname,
|
|
76
|
+
// for a readable id.
|
|
77
|
+
let canonical;
|
|
78
|
+
try {
|
|
79
|
+
canonical = new URL(origin.includes('://') ? origin : `https://${origin}`);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
canonical = new URL('https://invalid.invalid');
|
|
83
|
+
}
|
|
84
|
+
const host = canonical.hostname.toLowerCase();
|
|
85
|
+
const verb = method.toLowerCase().replace(/[^a-z]/g, '');
|
|
86
|
+
const tag = createHash('sha256').update(canonical.origin).digest('hex').slice(0, 8);
|
|
87
|
+
const slug = host.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
88
|
+
const head = `provider-${slug}`.slice(0, 64 - tag.length - verb.length - 2).replace(/-+$/, '');
|
|
89
|
+
return `${head}-${tag}-${verb}`;
|
|
90
|
+
}
|
|
91
|
+
const RULE_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
|
|
92
|
+
/** The one command that lets Segreant forward to an upstream that is not built in (a gateway, a hosted model). */
|
|
93
|
+
export function providerRuleCommand(url, method = 'POST') {
|
|
94
|
+
let origin;
|
|
95
|
+
try {
|
|
96
|
+
origin = new URL(new URL(url).origin);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (origin.protocol !== 'https:')
|
|
102
|
+
return null;
|
|
103
|
+
// Only methods a rule can name: printing `--method OPTIONS` would hand the
|
|
104
|
+
// user a command the rule validator refuses (PR #148 review).
|
|
105
|
+
if (!RULE_METHODS.has(method.toUpperCase()))
|
|
106
|
+
return null;
|
|
107
|
+
return `segreant egress apply --apply --mode controlled_cloud --id ${providerRuleId(origin.origin, method)} --purpose provider_inference --data-class provider_request --method ${method.toUpperCase()} --origin ${origin.origin} --path-prefix /`;
|
|
108
|
+
}
|
|
109
|
+
/** Built-in routes, the same in every mode: an operator who adds a webhook rule keeps their provider and their price list. */
|
|
110
|
+
function builtInRoute(target, intent, method) {
|
|
111
|
+
if (maintenanceTarget(target, intent, method)) {
|
|
112
|
+
// Same transport checks as a cloud rule: the address must resolve public.
|
|
113
|
+
return { allowed: true, target, targetClass: 'controlled_cloud', ruleId: MAINTENANCE_RULE_ID, reason: "Segreant's built-in public price list (GET only)" };
|
|
114
|
+
}
|
|
115
|
+
const provider = providerTarget(target, intent, method);
|
|
116
|
+
if (provider) {
|
|
117
|
+
return { allowed: true, target, targetClass: 'controlled_cloud', ruleId: provider, reason: "your request to the provider's official API (built in)" };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
3
121
|
function literalLoopback(hostname) {
|
|
4
122
|
const host = hostname.replace(/(^\[|\]$)/g, '').toLowerCase();
|
|
5
123
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '::ffff:127.0.0.1';
|
|
@@ -26,7 +144,8 @@ function matches(rule, target, intent) {
|
|
|
26
144
|
&& pathMatchesPrefix(target.pathname, rule.pathPrefix);
|
|
27
145
|
}
|
|
28
146
|
/**
|
|
29
|
-
* Decides before DNS. Local locked mode permits
|
|
147
|
+
* Decides before DNS. Local locked mode permits literal loopback and the exact
|
|
148
|
+
* built-in routes (price list, official provider APIs) only, so it
|
|
30
149
|
* never resolves a potential cloud host before rejecting it.
|
|
31
150
|
*/
|
|
32
151
|
export function evaluateEgressPolicy(config, intent) {
|
|
@@ -46,12 +165,17 @@ export function evaluateEgressPolicy(config, intent) {
|
|
|
46
165
|
return { allowed: false, target, reason: 'method is not supported' };
|
|
47
166
|
if (literalLoopback(target.hostname))
|
|
48
167
|
return { allowed: true, target, targetClass: 'loopback', reason: 'literal loopback target is permitted' };
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (config.mode !== 'controlled_cloud' || !Array.isArray(config.rules)) {
|
|
168
|
+
// A malformed persisted section (mode `invalid`) or any unknown shape refuses
|
|
169
|
+
// before the built-in routes: a typo never lets provider traffic through.
|
|
170
|
+
if (config.mode !== 'local_locked' && (config.mode !== 'controlled_cloud' || !Array.isArray(config.rules))) {
|
|
53
171
|
return { allowed: false, target, reason: 'unknown or malformed egress mode/config is fail-closed; only local_locked or controlled_cloud is supported' };
|
|
54
172
|
}
|
|
173
|
+
const builtIn = builtInRoute(target, intent, method);
|
|
174
|
+
if (builtIn)
|
|
175
|
+
return builtIn;
|
|
176
|
+
if (config.mode === 'local_locked') {
|
|
177
|
+
return { allowed: false, target, reason: "local_locked permits only literal loopback, the built-in public price list and the providers' official APIs; no other DNS lookup or dial occurs" };
|
|
178
|
+
}
|
|
55
179
|
if (target.protocol !== 'https:')
|
|
56
180
|
return { allowed: false, target, reason: 'controlled_cloud refuses plaintext HTTP outside literal loopback' };
|
|
57
181
|
const matching = config.rules.filter((rule) => matches(rule, target, { ...intent, method }));
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One recorded allocation run as a spreadsheet a budget owner can hand on.
|
|
3
|
+
*
|
|
4
|
+
* Built from the run exactly as it was recorded, never recomputed: a fresh
|
|
5
|
+
* computation under today's rules could disagree with the split someone
|
|
6
|
+
* already reviewed. Every money row is a part of the run's total, so the
|
|
7
|
+
* amount column adds up to the total to the microdollar and summing it is
|
|
8
|
+
* safe. The total itself is stated in the first row's note rather than given
|
|
9
|
+
* a row of its own, which would double any column sum.
|
|
10
|
+
*
|
|
11
|
+
* This is showback: a split of Segreant's own cost estimate. It is not a
|
|
12
|
+
* provider bill and is never added to metered or billed figures.
|
|
13
|
+
*/
|
|
14
|
+
import { toCsv } from './csv.js';
|
|
15
|
+
export const ALLOCATION_CSV_COLUMNS = [
|
|
16
|
+
'section', 'costCentre', 'costCentreId', 'ruleId', 'ruleVersion', 'method', 'shareOfRule',
|
|
17
|
+
'amountUsd', 'amountMicros', 'requests', 'basis', 'periodStartUtc', 'periodEndUtc',
|
|
18
|
+
'runId', 'recordedAtUtc', 'note',
|
|
19
|
+
];
|
|
20
|
+
const BASIS = 'showback split of the local estimate';
|
|
21
|
+
const REASONS = {
|
|
22
|
+
no_matching_rule: 'no rule claimed it',
|
|
23
|
+
no_driver_for_proportional_pool: 'a shared pool with nothing directly allocated to split it by',
|
|
24
|
+
target_cost_centre_archived: 'its cost centre was archived',
|
|
25
|
+
};
|
|
26
|
+
const METHODS = {
|
|
27
|
+
direct: 'all to one centre',
|
|
28
|
+
fixed_split: 'fixed shares',
|
|
29
|
+
proportional_to_direct: 'in proportion to direct spend',
|
|
30
|
+
};
|
|
31
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
32
|
+
/** Exact decimal dollars from integer microdollars, with no float in between. */
|
|
33
|
+
export function microsToUsd(micros) {
|
|
34
|
+
const sign = micros < 0 ? '-' : '';
|
|
35
|
+
const abs = Math.abs(micros);
|
|
36
|
+
return `${sign}${Math.trunc(abs / 1_000_000)}.${String(abs % 1_000_000).padStart(6, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
export function allocationRunToCsv(run, nameOf, demo) {
|
|
39
|
+
const r = run.result;
|
|
40
|
+
const own = [iso(r.periodStartMs), iso(r.periodEndMs), run.allocationRunId, iso(run.computedAtMs)];
|
|
41
|
+
const notes = [
|
|
42
|
+
`Total estimated spend in the period: $${microsToUsd(r.totalMicros)}. The rows below add up to it exactly.`,
|
|
43
|
+
'Showback: a split of Segreant\'s own cost estimate, not a provider bill. Do not add it to metered or billed figures.',
|
|
44
|
+
];
|
|
45
|
+
if (demo)
|
|
46
|
+
notes.push('Sample data, not measured.');
|
|
47
|
+
if (r.retention === undefined)
|
|
48
|
+
notes.push('Recorded before retention was tracked: whether rows were deleted from this period is unknown.');
|
|
49
|
+
else if (r.retention.truncated)
|
|
50
|
+
notes.push('Retention deleted request rows from inside this period; the split covers what survived, so the unallocated part may be understated.');
|
|
51
|
+
if (!r.conserves)
|
|
52
|
+
notes.push('This run did not conserve: allocated plus unallocated differs from the total. Do not rely on it.');
|
|
53
|
+
const rows = [];
|
|
54
|
+
rows.push(['run', '', '', '', '', '', '', '', '', '', BASIS, ...own, notes.join(' ')]);
|
|
55
|
+
for (const line of r.lines) {
|
|
56
|
+
rows.push(['allocated', nameOf(line.costCentreId), line.costCentreId, line.ruleId, line.ruleVersion,
|
|
57
|
+
METHODS[line.method] ?? line.method, (line.ratioParts / 1_000_000).toFixed(6),
|
|
58
|
+
microsToUsd(line.allocatedMicros), line.allocatedMicros, line.requestCount,
|
|
59
|
+
`${BASIS}; underlying cost basis ${line.sourceBasis}`, ...own, '']);
|
|
60
|
+
}
|
|
61
|
+
for (const u of r.unallocated) {
|
|
62
|
+
const top = u.topProjects.length > 0
|
|
63
|
+
? `mostly ${u.topProjects.map((p) => `${p.project} ($${microsToUsd(p.micros)})`).join(', ')}`
|
|
64
|
+
: '';
|
|
65
|
+
rows.push(['unallocated', '', '', '', '', '', '', microsToUsd(u.micros), u.micros, u.requestCount,
|
|
66
|
+
`${BASIS}; underlying cost basis ${u.sourceBasis}`, ...own,
|
|
67
|
+
[`not allocated: ${REASONS[u.reason] ?? u.reason.replace(/_/g, ' ')}`, top].filter(Boolean).join('; ')]);
|
|
68
|
+
}
|
|
69
|
+
return toCsv([...ALLOCATION_CSV_COLUMNS], rows);
|
|
70
|
+
}
|
|
@@ -17,8 +17,11 @@ const COLUMNS = [
|
|
|
17
17
|
'costBasis', 'trust',
|
|
18
18
|
];
|
|
19
19
|
/** This export is separate from request-ledger CSV and never adds provider totals to metered estimates. */
|
|
20
|
-
export function billingEvidenceToCsv(rows) {
|
|
21
|
-
|
|
20
|
+
export function billingEvidenceToCsv(rows, demo = false) {
|
|
21
|
+
// Sample data adds one last column, so a generated bill cannot be mistaken
|
|
22
|
+
// for a real one once the file leaves the terminal. A real export keeps its
|
|
23
|
+
// schema unchanged.
|
|
24
|
+
const lines = [[...COLUMNS, ...(demo ? ['sample'] : [])].map((column) => cell(column)).join(',')];
|
|
22
25
|
for (const row of rows) {
|
|
23
26
|
lines.push([
|
|
24
27
|
row.sourceSystem, row.billingAccountRef, row.sourceRecordId, row.sourceRecordSha256, row.firstImportId,
|
|
@@ -26,6 +29,7 @@ export function billingEvidenceToCsv(rows) {
|
|
|
26
29
|
new Date(row.observedAtMs).toISOString(), new Date(row.chargePeriodStartMs).toISOString(),
|
|
27
30
|
new Date(row.chargePeriodEndMs).toISOString(), row.chargeType, row.currency, formatUsdMicros(row.amountMicros),
|
|
28
31
|
row.amountMicros, row.usageUnit, row.usageQuantity, row.costBasis, row.trust,
|
|
32
|
+
...(demo ? ['generated sample data, not a provider bill'] : []),
|
|
29
33
|
].map((value, index) => cell(value, ![12, 13, 14, 17, 18].includes(index))).join(','));
|
|
30
34
|
}
|
|
31
35
|
return lines.join('\r\n') + '\r\n';
|
package/dist/export/csv.js
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
* who own the budget. This serializes the request ledger to CSV (spreadsheet-
|
|
6
6
|
* native) or leaves it as JSON. Pure string building, no dependencies.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
9
|
+
/**
|
|
10
|
+
* RFC-4180-ish cell: quote when it contains a comma, quote, or newline; double
|
|
11
|
+
* internal quotes. Labels come from tool logs and folder names, so text that a
|
|
12
|
+
* spreadsheet would read as a formula is prefixed with an apostrophe. Numbers,
|
|
13
|
+
* including negative amounts carried as strings, are left as numbers.
|
|
14
|
+
*/
|
|
9
15
|
function cell(v) {
|
|
10
16
|
if (v === null || v === undefined)
|
|
11
17
|
return '';
|
|
12
|
-
|
|
18
|
+
let s = String(v);
|
|
19
|
+
if (typeof v === 'string' && /^[=+\-@\t\r]/.test(s) && !NUMBER.test(s))
|
|
20
|
+
s = `'${s}`;
|
|
13
21
|
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
14
22
|
}
|
|
15
23
|
export function toCsv(headers, rows) {
|
package/dist/export/economic.js
CHANGED
|
@@ -76,14 +76,18 @@ const ECONOMIC_COLUMNS = [
|
|
|
76
76
|
export function economicRequestsToJson(rows) {
|
|
77
77
|
if (!Array.isArray(rows))
|
|
78
78
|
throw new Error('economic export rows must be an array');
|
|
79
|
-
|
|
79
|
+
// The canonical serializer bounds one record. Each row is bounded on its own;
|
|
80
|
+
// the number of rows is the ledger's history, so it is not. The joined form is
|
|
81
|
+
// byte-identical to canonicalising the array whole.
|
|
82
|
+
const parts = rows.map((row) => canonicalJson({
|
|
80
83
|
...row,
|
|
81
84
|
sourceMoney: row.sourceMoney === null ? null : { ...row.sourceMoney },
|
|
82
85
|
effectiveMoney: row.effectiveMoney === null ? null : { ...row.effectiveMoney },
|
|
83
86
|
sourceBases: [...row.sourceBases],
|
|
84
87
|
sourceEventIds: [...row.sourceEventIds],
|
|
85
88
|
correctionEventIds: [...row.correctionEventIds],
|
|
86
|
-
}))
|
|
89
|
+
}));
|
|
90
|
+
return `[${parts.join(',')}]\n`;
|
|
87
91
|
}
|
|
88
92
|
/** CSV exact export with compatibility dollars explicitly labelled as such. */
|
|
89
93
|
export function economicRequestsToCsv(rows) {
|