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.
Files changed (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. 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: unpricedPricingEvidence() };
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, startMs) {
200
+ function codingSession(ctx, idx, made) {
194
201
  const sessionId = `demo-sess-code-${idx}`;
195
- const project = pick(ctx.rng, PROJECTS);
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
- ctx.store.upsertSession(sessionId, project, source, startMs);
199
- ctx.sessions += 1;
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
- let t = startMs;
209
+ const specs = [];
210
+ let offset = 0;
202
211
  for (let i = 0; i < calls; i++) {
203
- addRequest(ctx, {
204
- tsEpochMs: t,
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
- t += int(ctx.rng, 2 * 60 * 1000, 25 * 60 * 1000);
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
- // Scattered work sessions over the period.
523
- const codingStarts = [11, 9, 7, 4].map((d) => now - d * DAY_MS + 10 * HOUR_MS);
524
- codingStarts.forEach((startMs, i) => codingSession(ctx, i + 1, startMs));
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,
@@ -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 = 20_000;
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
@@ -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 only literal loopback, so it
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
- if (config.mode === 'local_locked') {
50
- return { allowed: false, target, reason: 'local_locked permits only literal loopback; no non-loopback DNS lookup or dial occurs' };
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
- const lines = [COLUMNS.map((column) => cell(column)).join(',')];
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';
@@ -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
- /** RFC-4180-ish cell: quote when it contains a comma, quote, or newline; double internal quotes. */
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
- const s = String(v);
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) {
@@ -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
- return `${canonicalJson(rows.map((row) => ({
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
- })))}\n`;
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) {