@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,211 @@
1
+ // Constrained routing decisions (X-604).
2
+ //
3
+ // A decision is made in two stages that never swap places:
4
+ //
5
+ // 1. CONSTRAINTS. A candidate model is acceptable only if it is available, has every capability the task requires, is allowed to
6
+ // receive the task's data (the egress policy, egress/policy.js, decides; the capability manifest's egress check is used when a
7
+ // manifest is supplied), has a context window that fits the task, and has RELIABLE calibrated quality evidence that is fresh,
8
+ // narrow enough and whose LOWER bound meets the policy's minimum quality, and fits the remaining budget.
9
+ // 2. OPTIMIZATION. Only among acceptable candidates is cost (or latency) minimised, using MEASURED cost and latency from the
10
+ // calibration. A candidate with no measured cost is ranked behind every candidate that has one, on its catalog upper bound, and
11
+ // the explanation says so.
12
+ //
13
+ // Every candidate is evaluated against every constraint and ALL failures are recorded, so the explanation shows why each was
14
+ // rejected, not only the first reason. When nothing is acceptable the outcome is one of:
15
+ //
16
+ // fallback the existing capability route (the baseline), but only if it passes the HARD constraints (capability, privacy, context,
17
+ // budget). Its quality is then `unverified`, and the decision says so. `fallbackMode: 'block'` refuses this entirely.
18
+ // blocked with a code: `no-compliant-model`, `budget-exhausted`, `insufficient-quality-evidence`, `excessive-uncertainty`,
19
+ // `stale-evidence`, `invalid-policy`. A blocked decision selects nothing. A constraint is never relaxed to find a route.
20
+ //
21
+ // Pure given its inputs: the clock is a parameter (`now`), the egress evaluator is injectable, no network.
22
+
23
+ import { digestOf } from '../assurance/identity.js';
24
+ import { evaluateEgress } from '../../egress/policy.js';
25
+ import { costOf } from './economics.js';
26
+
27
+ export const DECISION_SCHEMA = 'agentic-security/routing-decision';
28
+ const DECISION_VERSION = '1.0.0';
29
+ export const CONSTRAINTS = Object.freeze(['availability', 'capability', 'privacy', 'context', 'quality', 'uncertainty', 'freshness', 'budget']);
30
+ export const BLOCK_CODES = Object.freeze(['no-compliant-model', 'budget-exhausted', 'insufficient-quality-evidence', 'excessive-uncertainty', 'stale-evidence', 'invalid-policy', 'invalid-task']);
31
+ const HARD = Object.freeze(['availability', 'capability', 'privacy', 'context']);
32
+ const DAY = 86_400_000;
33
+
34
+ function deepFreeze(v) {
35
+ if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const k of Object.keys(v)) deepFreeze(v[k]); }
36
+ return v;
37
+ }
38
+
39
+ export function validateRoutingPolicy(p) {
40
+ const errors = [];
41
+ const bad = (path, message) => errors.push({ code: 'BAD_POLICY', path, message });
42
+ if (!p || typeof p !== 'object') return { ok: false, errors: [{ code: 'BAD_POLICY', path: '', message: 'not an object' }] };
43
+ if (typeof p.version !== 'string' || !p.version) bad('version', 'a policy is versioned');
44
+ const frac = (k, lo, hiOpen) => { if (!(typeof p[k] === 'number' && Number.isFinite(p[k]) && p[k] >= lo && p[k] <= 1)) bad(k, `must be a number in [${lo}, 1]`); else if (hiOpen && p[k] === lo) bad(k, 'must be above 0'); };
45
+ frac('minQualityLower', 0, false); frac('maxIntervalWidth', 0, true);
46
+ if (!(typeof p.maxEvidenceAgeDays === 'number' && p.maxEvidenceAgeDays > 0)) bad('maxEvidenceAgeDays', 'must be a positive number');
47
+ if (!['cost', 'latency'].includes(p.objective)) bad('objective', "must be 'cost' or 'latency'");
48
+ if (p.fallbackMode !== undefined && !['baseline-unverified', 'block'].includes(p.fallbackMode)) bad('fallbackMode', "must be 'baseline-unverified' or 'block'");
49
+ if (!p.budget || !(typeof p.budget.remainingUsd === 'number' && Number.isFinite(p.budget.remainingUsd))) bad('budget.remainingUsd', 'a finite remaining budget is required');
50
+ if (!(Number.isInteger(p.expectedOutputTokens) && p.expectedOutputTokens >= 0)) bad('expectedOutputTokens', 'a non-negative integer bound on output size is required');
51
+ return { ok: errors.length === 0, errors };
52
+ }
53
+
54
+ /**
55
+ * A candidate from the provider catalog's price entry plus operator-supplied facts. The catalog carries prices and cache models but
56
+ * NOT capabilities or context windows, and this function does not invent them: a missing `maxContextTokens` stays null and the
57
+ * candidate is rejected on the context constraint as unknown.
58
+ */
59
+ export function candidateFromCatalog({ id, provider, modelVersion = null, priceBook, capabilities = [], maxContextTokens = null, endpoint = null, local = false, available = true }) {
60
+ return { id, provider, modelVersion, capabilities: [...capabilities].sort(), maxContextTokens, endpoint, local, available, price: priceBook?.entries?.[id] ?? null };
61
+ }
62
+
63
+ const fresh = (artifact, est, now, maxDays) => {
64
+ const latest = est?.latestObservationAt || artifact?.latestObservationAt || null;
65
+ if (!latest || Number.isNaN(Date.parse(latest))) return { fresh: false, ageDays: null, latest };
66
+ const ageDays = (Date.parse(now) - Date.parse(latest)) / DAY;
67
+ return { fresh: ageDays >= 0 && ageDays <= maxDays, ageDays: Math.round(ageDays * 1000) / 1000, latest };
68
+ };
69
+
70
+ function expectedCost(c, task, policy, est) {
71
+ const priced = c.price && costOf({
72
+ usage: { inputTokens: task.contextTokens, outputTokens: policy.expectedOutputTokens, cachedInputTokens: 0, source: 'measured' },
73
+ model: c.id, priceBook: { version: 'candidate-price', currency: 'USD', billingBasis: 'list-price', entries: { [c.id]: c.price } },
74
+ toolExecutionUsd: 0, // the bound covers model tokens only; tool execution is priced by the caller's runner
75
+ });
76
+ // The upper bound is the catalog price on the full request with NO cache credit: a ceiling for the budget check, not a measurement.
77
+ const upperUsd = priced && priced.totalStatus !== 'unknown' ? priced.totalUpperUsd : null;
78
+ const measuredP50 = est && est.costUsd && est.costUsd.n > 0 ? est.costUsd.p50 : null;
79
+ return { measuredP50Usd: measuredP50, boundUpperUsd: upperUsd, basis: measuredP50 !== null ? 'measured-median' : upperUsd !== null ? 'catalog-upper-bound' : 'unknown' };
80
+ }
81
+
82
+ /**
83
+ * Decide a route for one task.
84
+ *
85
+ * @param {object} o
86
+ * @param {object} o.task from buildRoutingTask
87
+ * @param {object[]} o.candidates candidate descriptors (see candidateFromCatalog)
88
+ * @param {object|null} o.calibration buildCalibration() artifact
89
+ * @param {object} o.policy versioned routing policy (validateRoutingPolicy)
90
+ * @param {string} o.now ISO timestamp; the only clock this function sees
91
+ * @param {{model:string, effort?:string, reason?:string}|null} o.baselineRoute the existing capability route, used as the fallback
92
+ * @param {(ctx)=>object} [o.egress] egress evaluator, default evaluateEgress
93
+ * @param {string} [o.scanRoot]
94
+ * @param {((egressCtx)=>object)|null} [o.manifestEgress] optional capability-manifest egress check, used INSTEAD of `egress`; for example
95
+ * `(ctx) => evaluateManifestEgress(bound, binding, ctx)` from capabilities/egress.js, which applies the task grant AND the operator policy
96
+ */
97
+ export function routeConstrained({ task, candidates, calibration = null, policy, now, baselineRoute = null, egress = evaluateEgress, scanRoot = null, manifestEgress = null, allowSynthetic = false }) {
98
+ const header = { schema: DECISION_SCHEMA, schemaVersion: DECISION_VERSION, policyVersion: policy?.version ?? null, taskId: task?.taskId ?? null, stratum: task?.stratum ?? null };
99
+ const finish = (d) => { const out = { ...header, ...d }; out.decisionHash = digestOf({ ...out, decisionHash: undefined }); return deepFreeze(JSON.parse(JSON.stringify(out))); };
100
+ const pv = validateRoutingPolicy(policy);
101
+ if (!pv.ok) return finish({ status: 'blocked', code: 'invalid-policy', reason: pv.errors.map((e) => `${e.path}: ${e.message}`).join('; '), selected: null, candidates: [], alternatives: [], fallback: null });
102
+ if (!task || !task.stratum || !Array.isArray(task.requiredCapabilities) || Number.isNaN(Date.parse(now))) {
103
+ return finish({ status: 'blocked', code: 'invalid-task', reason: 'a valid task record and an ISO `now` are required', selected: null, candidates: [], alternatives: [], fallback: null });
104
+ }
105
+ const permitSynthetic = allowSynthetic === true || policy.allowSynthetic === true;
106
+ const remaining = policy.budget.remainingUsd;
107
+ const evalOne = (c) => {
108
+ const rejections = [];
109
+ const rej = (constraint, code, detail) => rejections.push({ constraint, code, detail });
110
+ if (c.available === false) rej('availability', 'unavailable', 'the model is not currently available');
111
+ const missing = task.requiredCapabilities.filter((cap) => !(c.capabilities || []).includes(cap));
112
+ if (missing.length) rej('capability', 'missing-capability', `lacks: ${missing.join(', ')}`);
113
+ // privacy: the egress policy decides whether this task's data class may go to this endpoint/model, and local endpoints are judged the same way
114
+ if (!c.endpoint) rej('privacy', 'no-endpoint', 'no endpoint is configured, so the egress policy cannot clear it');
115
+ else {
116
+ const ectx = { scanRoot, purpose: 'routing-decision', endpoint: c.endpoint, model: c.id, dataClass: task.dataClass, contextTokens: task.contextTokens };
117
+ let e;
118
+ try { e = manifestEgress ? manifestEgress(ectx) : egress(ectx); } catch { e = { allowed: false, code: 'egress-error' }; }
119
+ if (!e || e.allowed !== true) rej('privacy', 'egress-denied', (e && (e.reason || e.code || e.policy?.reason)) || 'egress policy denied');
120
+ }
121
+ if (c.maxContextTokens === null || c.maxContextTokens === undefined) rej('context', 'context-unknown', 'the model\'s context window is not known');
122
+ else if (c.maxContextTokens < task.contextTokens + policy.expectedOutputTokens) rej('context', 'context-too-small', `needs ${task.contextTokens + policy.expectedOutputTokens} tokens, window is ${c.maxContextTokens}`);
123
+
124
+ // quality evidence for exactly this model and version
125
+ const est = calibration ? calibration.estimates.find((x) => x.model === c.id && (x.modelVersion ?? null) === (c.modelVersion ?? null) && x.stratum === task.stratum) : null;
126
+ const anyVersion = calibration ? calibration.estimates.some((x) => x.model === c.id && x.stratum === task.stratum) : false;
127
+ let quality = null;
128
+ if (!est) rej('quality', anyVersion ? 'model-version-mismatch' : 'no-quality-evidence', anyVersion ? 'evidence exists only for another version of this model' : 'no calibration for this model and stratum');
129
+ else {
130
+ quality = { estimate: est.accuracy, low: est.interval.low, high: est.interval.high, n: est.n, label: est.label, shift: est.shift };
131
+ const reliable = est.label === 'reliable' || (est.label === 'reliable-synthetic' && permitSynthetic);
132
+ if (!reliable) rej('quality', est.label === 'reliable-synthetic' ? 'synthetic-evidence' : `not-reliable:${est.label}`, est.reasons.length ? est.reasons[0] : `calibration label is ${est.label}`);
133
+ else if (est.interval.low === null || est.interval.low < policy.minQualityLower) rej('quality', 'below-minimum-quality', `lower bound ${est.interval.low} is under the required ${policy.minQualityLower}`);
134
+ if (est.interval.low !== null && est.interval.high !== null && est.interval.high - est.interval.low > policy.maxIntervalWidth) rej('uncertainty', 'excessive-uncertainty', `interval width ${(est.interval.high - est.interval.low).toFixed(3)} exceeds ${policy.maxIntervalWidth}`);
135
+ else if (est.interval.low === null) rej('uncertainty', 'unmeasured-uncertainty', 'no uncertainty interval could be measured');
136
+ }
137
+ const fr = est ? fresh(calibration, est, now, policy.maxEvidenceAgeDays) : { fresh: null, ageDays: null, latest: null };
138
+ if (est && !fr.fresh) rej('freshness', 'stale-evidence', fr.latest ? `evidence is ${fr.ageDays} days old, over ${policy.maxEvidenceAgeDays}` : 'evidence has no observation time');
139
+
140
+ const cost = expectedCost(c, task, policy, est);
141
+ if (cost.boundUpperUsd === null && cost.measuredP50Usd === null) rej('budget', 'cost-unknown', 'no price and no measured cost: an unknown cost is never treated as free');
142
+ else {
143
+ // budget uses the LARGER of the catalog ceiling and the measured median, so neither can understate what a task may cost
144
+ const need = Math.max(cost.boundUpperUsd ?? 0, cost.measuredP50Usd ?? 0);
145
+ if (!(remaining > 0)) rej('budget', 'budget-exhausted', 'no budget remains');
146
+ else if (need > remaining) rej('budget', 'over-budget', `expected at most ${need} against ${remaining} remaining`);
147
+ if (typeof policy.budget.maxUsdPerTask === 'number' && need > policy.budget.maxUsdPerTask) rej('budget', 'over-task-cap', `expected at most ${need} against a per-task cap of ${policy.budget.maxUsdPerTask}`);
148
+ }
149
+ return {
150
+ model: c.id, provider: c.provider ?? null, modelVersion: c.modelVersion ?? null, accepted: rejections.length === 0, rejections,
151
+ quality, evidence: est ? { label: est.label, latestObservationAt: fr.latest, ageDays: fr.ageDays, fresh: fr.fresh, maxAgeDays: policy.maxEvidenceAgeDays, calibrationHash: calibration.calibrationHash } : null,
152
+ expectedCost: cost, latencyMs: est ? est.latencyMs : null, hardCompliant: rejections.every((r) => !HARD.includes(r.constraint)),
153
+ fitsBudget: rejections.every((r) => r.constraint !== 'budget'),
154
+ };
155
+ };
156
+ const evaluated = [...candidates].sort((a, b) => (a.id < b.id ? -1 : 1)).map(evalOne);
157
+ const accepted = evaluated.filter((e) => e.accepted);
158
+
159
+ const rank = (e) => {
160
+ if (policy.objective === 'latency') return [e.latencyMs && e.latencyMs.p50 !== null ? 0 : 1, e.latencyMs?.p50 ?? Infinity, e.expectedCost.measuredP50Usd ?? e.expectedCost.boundUpperUsd ?? Infinity, e.model];
161
+ return [e.expectedCost.measuredP50Usd !== null ? 0 : 1, e.expectedCost.measuredP50Usd ?? e.expectedCost.boundUpperUsd ?? Infinity, e.latencyMs?.p50 ?? Infinity, e.model];
162
+ };
163
+ const cmp = (a, b) => { const ra = rank(a); const rb = rank(b); for (let i = 0; i < ra.length; i++) if (ra[i] !== rb[i]) return ra[i] < rb[i] ? -1 : 1; return 0; };
164
+ const summary = (e) => ({ model: e.model, modelVersion: e.modelVersion, accepted: e.accepted, rejections: e.rejections, quality: e.quality, evidence: e.evidence, expectedCost: e.expectedCost, latencyMs: e.latencyMs });
165
+
166
+ if (accepted.length) {
167
+ const ordered = [...accepted].sort(cmp);
168
+ const sel = ordered[0];
169
+ return finish({
170
+ status: 'routed', code: null, reason: `selected ${sel.model}: every constraint met, lowest ${policy.objective === 'latency' ? 'p50 latency' : 'cost'} among ${accepted.length} acceptable candidate(s) (${sel.expectedCost.basis})`,
171
+ selected: { model: sel.model, provider: sel.provider, modelVersion: sel.modelVersion, via: 'optimized' },
172
+ candidates: evaluated.map(summary),
173
+ alternatives: ordered.slice(1).map((e) => ({ model: e.model, why: `acceptable, but ${policy.objective === 'latency' ? 'slower' : 'costlier'} than the selection` })),
174
+ expectedBounds: { quality: { low: sel.quality.low, high: sel.quality.high }, costUsd: { measuredMedian: sel.expectedCost.measuredP50Usd, upperBound: sel.expectedCost.boundUpperUsd, basis: sel.expectedCost.basis }, latencyMs: sel.latencyMs },
175
+ evidence: sel.evidence, fallback: { considered: false, used: false }, synthetic: calibration?.synthetic === true || sel.quality.label === 'reliable-synthetic',
176
+ });
177
+ }
178
+
179
+ // Nothing is acceptable. Classify why, then offer the bounded fallback or block.
180
+ const hard = evaluated.filter((e) => e.hardCompliant);
181
+ const anyRej = (code) => evaluated.some((e) => e.rejections.some((r) => r.code === code));
182
+ const budgetExhausted = !(remaining > 0) || (hard.length > 0 && hard.every((e) => !e.fitsBudget));
183
+ let code;
184
+ if (!hard.length) code = 'no-compliant-model';
185
+ else if (budgetExhausted) code = 'budget-exhausted';
186
+ else if (hard.every((e) => e.rejections.some((r) => r.code === 'stale-evidence'))) code = 'stale-evidence';
187
+ else if (hard.some((e) => e.rejections.some((r) => r.code === 'excessive-uncertainty')) && !anyRej('no-quality-evidence')) code = 'excessive-uncertainty';
188
+ else code = 'insufficient-quality-evidence';
189
+
190
+ const fb = baselineRoute && baselineRoute.model ? evaluated.find((e) => e.model === baselineRoute.model) : null;
191
+ const fallbackInfo = { considered: !!baselineRoute, model: baselineRoute?.model ?? null, mode: policy.fallbackMode ?? 'baseline-unverified', used: false, qualityVerified: false };
192
+ const base = { candidates: evaluated.map(summary), alternatives: [], expectedBounds: null, evidence: null, synthetic: calibration?.synthetic === true };
193
+ const canFallback = code !== 'no-compliant-model' && code !== 'budget-exhausted' && (policy.fallbackMode ?? 'baseline-unverified') === 'baseline-unverified'
194
+ && fb && fb.hardCompliant && fb.fitsBudget;
195
+ // A fallback that could not be taken because it breaks a hard constraint or the budget is blocked, never relaxed.
196
+ if (canFallback) {
197
+ return finish({
198
+ ...base, status: 'fallback', code, reason: `no model has reliable quality evidence that meets the policy (${code}); using the existing capability route ${fb.model}, which passes the capability, privacy, context and budget constraints. Its quality is UNVERIFIED.`,
199
+ selected: { model: fb.model, provider: fb.provider, modelVersion: fb.modelVersion, via: 'fallback' },
200
+ expectedBounds: { quality: null, costUsd: { measuredMedian: fb.expectedCost.measuredP50Usd, upperBound: fb.expectedCost.boundUpperUsd, basis: fb.expectedCost.basis }, latencyMs: fb.latencyMs },
201
+ fallback: { ...fallbackInfo, used: true },
202
+ });
203
+ }
204
+ return finish({
205
+ ...base, status: 'blocked', code,
206
+ reason: code === 'no-compliant-model' ? 'no candidate satisfies the capability, privacy and context constraints; no constraint was relaxed'
207
+ : code === 'budget-exhausted' ? 'the remaining budget cannot cover any compliant candidate; no constraint was relaxed'
208
+ : `${code}: no reliable evidence clears the policy and the baseline fallback ${fallbackInfo.mode === 'block' ? 'is disabled by policy' : 'does not satisfy the hard constraints or the budget'}`,
209
+ selected: null, fallback: fallbackInfo,
210
+ });
211
+ }
@@ -0,0 +1,137 @@
1
+ // Drift detection and explicit invalidation (X-606.AC01).
2
+ //
3
+ // A calibration is evidence about one model version, one price book, one set of record schemas and one measured quality level. When
4
+ // any of those moves, the estimates built on them no longer describe what the router would be choosing, and silently carrying them
5
+ // forward is the failure this module exists to prevent.
6
+ //
7
+ // snapshotRoutingBasis what the policy was promoted on: calibration hash, per-model version, per-model price digest, schema versions
8
+ // assessDrift compare a snapshot with the present, plus a window of recent decided outcomes
9
+ // applyInvalidations a DERIVED calibration in which the affected estimates are labelled `invalidated` (quality, version, schema)
10
+ // or have their measured cost removed (pricing), with the reason on the estimate and the artifact
11
+ //
12
+ // Kinds of drift and what each invalidates:
13
+ // model-version the candidate's current version differs from the calibrated one (or is no longer reported): that model's estimates
14
+ // schema a record or calibration schema version differs: every estimate
15
+ // pricing the model's price entry changed: its measured COST (decisions fall back to the catalog upper bound); quality stands
16
+ // quality-shift recent decided outcomes sit significantly below the calibrated lower bound: that model/stratum estimate
17
+ //
18
+ // State on any invalidation is the policy's documented choice: `fallback` (the existing capability route stands, because invalidated
19
+ // estimates are refused by the constrained router) or `shadow` (proposals are recorded, production is untouched). It is never
20
+ // `active`, and the assessment says which estimates were invalidated and why. No clock and no network: `now` is not even needed.
21
+
22
+ import { digestOf } from '../assurance/identity.js';
23
+ import { wilsonInterval } from '../evaluation/interval.js';
24
+ import { CALIBRATION_SCHEMA } from './calibration.js';
25
+ import { ROUTING_OUTCOME_SCHEMA } from './outcomes.js';
26
+ import { DECISION_SCHEMA } from './decide.js';
27
+
28
+ export const BASIS_SCHEMA = 'agentic-security/routing-basis-snapshot';
29
+ export const DRIFT_KINDS = Object.freeze(['model-version', 'schema', 'pricing', 'quality-shift']);
30
+ export const DRIFT_STATES = Object.freeze(['active', 'fallback', 'shadow']);
31
+ export const DRIFT_DEFAULTS = Object.freeze({ onInvalidation: 'fallback', minRecentDecided: 30 });
32
+ export const SCHEMA_VERSIONS = Object.freeze({ calibration: `${CALIBRATION_SCHEMA}@1.0.0`, outcome: ROUTING_OUTCOME_SCHEMA, decision: DECISION_SCHEMA });
33
+
34
+ /**
35
+ * @param {object} o
36
+ * @param {object} o.calibration buildCalibration() artifact
37
+ * @param {object} o.priceBook priceBookFromCatalog() result
38
+ * @param {{id:string, modelVersion:string|null}[]} o.models the candidates the policy was promoted on
39
+ */
40
+ export function snapshotRoutingBasis({ calibration, priceBook, models, schemas = SCHEMA_VERSIONS }) {
41
+ const priceDigests = {};
42
+ for (const m of models) priceDigests[m.id] = priceBook?.entries?.[m.id] ? digestOf(priceBook.entries[m.id]) : null;
43
+ const quality = {};
44
+ for (const e of calibration?.estimates ?? []) {
45
+ if (e.level === 'stratum' && (e.label === 'reliable' || e.label === 'reliable-synthetic')) quality[`${e.model}|${e.modelVersion ?? ''}|${e.stratum}`] = { low: e.interval.low, accuracy: e.accuracy, n: e.n };
46
+ }
47
+ const snap = {
48
+ schema: BASIS_SCHEMA, calibrationHash: calibration?.calibrationHash ?? null, priceVersion: priceBook?.version ?? null,
49
+ models: Object.fromEntries(models.map((m) => [m.id, m.modelVersion ?? null])), priceDigests, schemas: { ...schemas }, quality,
50
+ synthetic: calibration?.synthetic === true,
51
+ };
52
+ snap.snapshotHash = digestOf({ ...snap, snapshotHash: undefined });
53
+ return Object.freeze(snap);
54
+ }
55
+
56
+ /**
57
+ * @param {object} o
58
+ * @param {object} o.basis snapshotRoutingBasis() result
59
+ * @param {object} o.current { models: [{id, modelVersion}], priceBook, schemas }
60
+ * @param {object[]} [o.recentOutcomes] decided outcomes observed since the snapshot
61
+ * @param {{onInvalidation?:'fallback'|'shadow', minRecentDecided?:number}} [o.policy]
62
+ */
63
+ export function assessDrift({ basis, current, recentOutcomes = [], policy = {} }) {
64
+ const onInvalidation = policy.onInvalidation ?? DRIFT_DEFAULTS.onInvalidation;
65
+ const minRecent = policy.minRecentDecided ?? DRIFT_DEFAULTS.minRecentDecided;
66
+ const invalidations = [];
67
+ const add = (kind, model, modelVersion, stratum, reason) => invalidations.push({ kind, model, modelVersion, stratum, reason });
68
+ if (!basis || basis.schema !== BASIS_SCHEMA || digestOf({ ...basis, snapshotHash: undefined }) !== basis.snapshotHash) {
69
+ return { state: DRIFT_STATES.includes(onInvalidation) ? onInvalidation : 'fallback', invalidations: [{ kind: 'schema', model: null, modelVersion: null, stratum: null, reason: 'the basis snapshot is missing or does not match its own hash' }], documentedFallback: onInvalidation, unchanged: false };
70
+ }
71
+ const have = new Map((current?.models ?? []).map((m) => [m.id, m.modelVersion ?? null]));
72
+ for (const [id, version] of Object.entries(basis.models)) {
73
+ if (!have.has(id)) { add('model-version', id, version, null, `${id} is no longer offered (calibrated at version ${version ?? 'unversioned'})`); continue; }
74
+ if (have.get(id) !== version) add('model-version', id, version, null, `${id} moved from version ${version ?? 'unversioned'} to ${have.get(id) ?? 'unreported'}; estimates for the old version do not carry over`);
75
+ const priceNow = current?.priceBook?.entries?.[id] ? digestOf(current.priceBook.entries[id]) : null;
76
+ if (priceNow !== basis.priceDigests[id]) add('pricing', id, version, null, `the price entry for ${id} changed (price book ${basis.priceVersion} to ${current?.priceBook?.version ?? 'unknown'}); measured cost estimates are no longer valid`);
77
+ }
78
+ for (const [k, v] of Object.entries(basis.schemas)) {
79
+ const now = current?.schemas?.[k];
80
+ if (now !== v) add('schema', null, null, null, `the ${k} schema changed from ${v} to ${now ?? 'unreported'}`);
81
+ }
82
+ // measured quality: recent decided outcomes per (model, version, stratum) against the calibrated lower bound
83
+ const groups = new Map();
84
+ for (const o of recentOutcomes) {
85
+ if (o.outcome !== 'correct' && o.outcome !== 'incorrect') continue;
86
+ const k = `${o.model}|${o.modelVersion ?? ''}|${o.stratum}`;
87
+ if (!groups.has(k)) groups.set(k, { correct: 0, n: 0, model: o.model, modelVersion: o.modelVersion ?? null, stratum: o.stratum });
88
+ const g = groups.get(k); g.n += 1; if (o.outcome === 'correct') g.correct += 1;
89
+ }
90
+ for (const [k, g] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
91
+ const base = basis.quality[k];
92
+ if (!base || g.n < minRecent || base.low === null) continue;
93
+ const w = wilsonInterval(g.correct, g.n);
94
+ if (w.status === 'measured' && w.high < base.low) add('quality-shift', g.model, g.modelVersion, g.stratum, `recent quality ${g.correct}/${g.n} (95% upper ${w.high.toFixed(3)}) is below the calibrated lower bound ${base.low}`);
95
+ }
96
+ const state = invalidations.length ? (DRIFT_STATES.includes(onInvalidation) && onInvalidation !== 'active' ? onInvalidation : 'fallback') : 'active';
97
+ return {
98
+ state, invalidations, unchanged: invalidations.length === 0,
99
+ documentedFallback: invalidations.length ? (state === 'shadow' ? 'shadow: proposals are recorded and production selection is unchanged' : 'fallback: the existing capability route stands; invalidated estimates are refused by the constrained router') : null,
100
+ kinds: [...new Set(invalidations.map((i) => i.kind))].sort(),
101
+ };
102
+ }
103
+
104
+ /** A derived calibration with the assessed invalidations applied. The original artifact is not modified. */
105
+ export function applyInvalidations(calibration, assessment) {
106
+ const list = assessment?.invalidations ?? [];
107
+ const schemaWide = list.find((i) => i.kind === 'schema');
108
+ const estimates = calibration.estimates.map((e) => {
109
+ const hits = list.filter((i) => {
110
+ if (i.kind === 'schema') return true;
111
+ if (i.model !== e.model) return false;
112
+ if (i.kind === 'model-version') return (i.modelVersion ?? null) === (e.modelVersion ?? null);
113
+ if (i.kind === 'pricing') return (i.modelVersion ?? null) === (e.modelVersion ?? null);
114
+ if (i.kind === 'quality-shift') return i.stratum === e.stratum && (i.modelVersion ?? null) === (e.modelVersion ?? null);
115
+ return false;
116
+ });
117
+ if (!hits.length) return e;
118
+ const qualityHits = hits.filter((h) => h.kind !== 'pricing');
119
+ const next = JSON.parse(JSON.stringify(e));
120
+ if (qualityHits.length) {
121
+ next.label = 'invalidated';
122
+ next.reasons = qualityHits.map((h) => `${h.kind}: ${h.reason}`);
123
+ next.fallback = { policy: 'baseline-model', model: calibration.pairing?.baselineModel ?? null, note: 'this estimate was invalidated; the baseline route stands' };
124
+ }
125
+ if (hits.some((h) => h.kind === 'pricing' || h.kind === 'schema')) {
126
+ next.costUsd = { n: 0, unknown: e.costUsd?.n ?? 0, p50: null, invalidated: true, reason: hits.filter((h) => h.kind === 'pricing' || h.kind === 'schema').map((h) => h.reason)[0] };
127
+ }
128
+ next.invalidatedBy = hits.map((h) => h.kind).sort();
129
+ return next;
130
+ });
131
+ const out = JSON.parse(JSON.stringify({ ...calibration, estimates }));
132
+ out.derivedFrom = calibration.calibrationHash;
133
+ out.invalidations = list;
134
+ out.invalidatedAll = !!schemaWide;
135
+ out.calibrationHash = digestOf({ ...out, calibrationHash: undefined });
136
+ return Object.freeze(out);
137
+ }
@@ -0,0 +1,185 @@
1
+ // Auditable provider economics (X-602).
2
+ //
3
+ // Turns token usage into a cost that says how it was obtained. Extends the provider catalog (posture/provider-catalog.js, which
4
+ // already carries the dated $/1M-token rates and each provider's cache model) rather than keeping a second price table, and sits
5
+ // beside cache-economics.js, which prices Claude Code's own transcript. That module and the interactive cost advisor are unchanged.
6
+ //
7
+ // What "auditable" means here:
8
+ //
9
+ // - A cost is split into input (uncached prompt tokens), cached input, output, retries and tool execution, and the result names the
10
+ // price book version, currency and billing basis it was computed against, so two costs are comparable only when those match.
11
+ // - UNKNOWN IS NOT ZERO. A missing price, a missing token count, an unknown cache split, a subscription whose marginal cost is not
12
+ // metered: each yields `unknown` (null) or a BOUNDED estimate with a lower and an upper value, never 0. A total with an unknown
13
+ // component is unknown; it does not quietly sum the parts that happened to be known.
14
+ // - MEASURED AND PREDICTED NEVER MIX. `measured` is computed only from usage the provider reported. A prediction (from a context
15
+ // size, say) goes in `predicted`, and the measured fields stay null when no measurement exists. A prediction never fills in for a
16
+ // measurement, and the budget path in decide.js uses the bounded upper estimate, labelled as such, not a "measured" number.
17
+ //
18
+ // Pure: no fs, no clock, no network. Money is a plain number of the book's currency; sums use the same order every time so a result
19
+ // is reproducible.
20
+
21
+ import { PROVIDERS, SOURCED_AT } from '../provider-catalog.js';
22
+ import { digestOf } from '../assurance/identity.js';
23
+
24
+ export const PRICE_BOOK_SCHEMA = 'agentic-security/routing-price-book';
25
+ export const BILLING_BASES = Object.freeze(['list-price', 'contract', 'subscription', 'unknown']);
26
+ /** A basis whose per-token price is not the whole story: a flat subscription has no metered marginal cost. */
27
+ const UNMETERED_BASES = Object.freeze(['subscription', 'unknown']);
28
+ export const COST_STATUS = Object.freeze({ measured: 'measured', bounded: 'bounded-estimate', unknown: 'unknown' });
29
+ const PER = 1_000_000;
30
+
31
+ /**
32
+ * The price book derived from the provider catalog. `version` pins the catalog snapshot date, so a price change is a new version, and
33
+ * the catalog's own warning stands: those rates are a dated snapshot, not live truth.
34
+ */
35
+ export function priceBookFromCatalog({ providers = PROVIDERS, sourcedAt = SOURCED_AT, billingBasis = 'list-price', currency = 'USD' } = {}) {
36
+ const entries = {};
37
+ for (const [provider, p] of Object.entries(providers)) {
38
+ for (const m of p.models) {
39
+ entries[m.id] = {
40
+ provider, in: m.in ?? null, out: m.out ?? null, cached: m.cached ?? null,
41
+ cacheKind: p.cache?.kind ?? null, cacheMinPrefixTokens: p.cache?.minPrefixTokens ?? null,
42
+ };
43
+ }
44
+ }
45
+ const book = { schema: PRICE_BOOK_SCHEMA, version: `provider-catalog@${sourcedAt}`, currency, billingBasis, unit: 'per-1M-tokens', sourcedAt, entries };
46
+ book.digest = digestOf({ ...book, digest: undefined });
47
+ return book;
48
+ }
49
+
50
+ export function validatePriceBook(b) {
51
+ const errors = [];
52
+ const bad = (path, message) => errors.push({ code: 'BAD_PRICE_BOOK', path, message });
53
+ if (!b || typeof b !== 'object') return { ok: false, errors: [{ code: 'BAD_PRICE_BOOK', path: '', message: 'not an object' }] };
54
+ if (b.schema !== PRICE_BOOK_SCHEMA) bad('schema', `expected ${PRICE_BOOK_SCHEMA}`);
55
+ if (typeof b.version !== 'string' || !b.version) bad('version', 'a price book has a version');
56
+ if (typeof b.currency !== 'string' || !/^[A-Z]{3}$/.test(b.currency)) bad('currency', 'must be a three-letter code');
57
+ if (!BILLING_BASES.includes(b.billingBasis)) bad('billingBasis', `must be one of ${BILLING_BASES.join(', ')}`);
58
+ if (!b.entries || typeof b.entries !== 'object') bad('entries', 'missing');
59
+ else {
60
+ for (const [id, e] of Object.entries(b.entries)) {
61
+ for (const f of ['in', 'out', 'cached']) {
62
+ if (e[f] !== null && e[f] !== undefined && !(typeof e[f] === 'number' && Number.isFinite(e[f]) && e[f] >= 0)) bad(`entries.${id}.${f}`, 'a rate is a non-negative number, or null when unknown (never 0 for unknown)');
63
+ }
64
+ }
65
+ }
66
+ return { ok: errors.length === 0, errors };
67
+ }
68
+
69
+ const pick = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
70
+ const UNKNOWN = Object.freeze({ usd: null, lowerUsd: null, upperUsd: null, status: COST_STATUS.unknown });
71
+ const unknown = (reason) => ({ ...UNKNOWN, reason });
72
+ const exact = (usd) => ({ usd, lowerUsd: usd, upperUsd: usd, status: COST_STATUS.measured });
73
+ const bounded = (lowerUsd, upperUsd, reason) => ({ usd: null, lowerUsd, upperUsd, status: COST_STATUS.bounded, reason });
74
+
75
+ /**
76
+ * Can the prompt prefix be cached at all? `ineligible` is a fact (the provider's own minimum prefix is not met, or it has no cache);
77
+ * `unknown` is everything else, because eligible is not the same as hit.
78
+ */
79
+ export function cacheEligibility(entry, inputTokens) {
80
+ if (!entry || entry.cacheKind === null || entry.cacheKind === undefined) return 'unknown';
81
+ if (Number.isInteger(inputTokens) && Number.isInteger(entry.cacheMinPrefixTokens) && inputTokens < entry.cacheMinPrefixTokens) return 'ineligible';
82
+ return 'unknown';
83
+ }
84
+
85
+ /** Cost of the token components of ONE attempt from MEASURED usage. */
86
+ function attemptCost(usage, entry) {
87
+ const inTok = usage.inputTokens;
88
+ const outTok = usage.outputTokens;
89
+ const out = outTok === null || pick(entry?.out) === null ? unknown(outTok === null ? 'output tokens not reported' : 'no output price') : exact((outTok * entry.out) / PER);
90
+ let input; let cachedInput;
91
+ if (inTok === null || pick(entry?.in) === null) {
92
+ const why = inTok === null ? 'input tokens not reported' : 'no input price';
93
+ input = unknown(why); cachedInput = unknown(why);
94
+ } else if (usage.cachedInputTokens !== null && usage.cachedInputTokens !== undefined) {
95
+ const cached = usage.cachedInputTokens;
96
+ input = exact(((inTok - cached) * entry.in) / PER);
97
+ cachedInput = cached === 0 ? exact(0) : (pick(entry.cached) === null ? unknown('cached input tokens reported but no cached-input price') : exact((cached * entry.cached) / PER));
98
+ } else if (cacheEligibility(entry, inTok) === 'ineligible') {
99
+ // Below the provider's minimum cacheable prefix, so no token can have been served from cache: a fact, not a guess.
100
+ input = exact((inTok * entry.in) / PER); cachedInput = exact(0);
101
+ } else if (pick(entry.cached) === null) {
102
+ input = unknown('cache split not reported and no cached-input price to bound it'); cachedInput = unknown('cache split not reported');
103
+ } else {
104
+ // Cache split unknown: the cached share is somewhere in [0, inTok]. Bound the COMBINED prompt cost, and say so on both parts.
105
+ const lo = (inTok * Math.min(entry.in, entry.cached)) / PER;
106
+ const hi = (inTok * Math.max(entry.in, entry.cached)) / PER;
107
+ input = bounded(lo, hi, 'cache split not reported: the prompt cost lies between all-cached and all-uncached; the bound covers input and cached input together');
108
+ cachedInput = { ...input, combinedWithInput: true };
109
+ }
110
+ return { input, cachedInput, output: out };
111
+ }
112
+
113
+ const sumParts = (parts) => {
114
+ if (parts.some((p) => p.status === COST_STATUS.unknown)) return { ...UNKNOWN, reason: 'a component is unknown, so the total is unknown' };
115
+ const lowerUsd = parts.reduce((a, p) => a + p.lowerUsd, 0);
116
+ const upperUsd = parts.reduce((a, p) => a + p.upperUsd, 0);
117
+ if (parts.every((p) => p.status === COST_STATUS.measured)) return exact(lowerUsd);
118
+ return { usd: null, lowerUsd, upperUsd, status: COST_STATUS.bounded };
119
+ };
120
+
121
+ /**
122
+ * Price one task run.
123
+ *
124
+ * @param {object} o
125
+ * @param {{inputTokens:number|null, outputTokens:number|null, cachedInputTokens:number|null, source:string}} o.usage the FINAL attempt's usage
126
+ * @param {string} o.model
127
+ * @param {object} o.priceBook
128
+ * @param {Array<{usage?:object, billable?:boolean}>} [o.retries] earlier attempts. An attempt with no reported usage is bounded by
129
+ * `estimatedRequestTokens` (a failed request may have been billed for its input), never priced at 0.
130
+ * @param {number|null} [o.estimatedRequestTokens] request size estimate used ONLY to bound unreported retries
131
+ * @param {number|null} [o.toolExecutionUsd] null/undefined means not measured (unknown), not free
132
+ * @param {number|null} [o.predictedTokens] a prediction; reported under `predicted`, never under `measured`
133
+ * @returns the cost with `priceVersion`, `currency`, `billingBasis`, and separate `measured` / `predicted` sections
134
+ */
135
+ export function costOf({ usage, model, priceBook, retries = [], estimatedRequestTokens = null, toolExecutionUsd = undefined, predictedTokens = null } = {}) {
136
+ const meta = {
137
+ priceVersion: priceBook?.version ?? null, currency: priceBook?.currency ?? null, billingBasis: priceBook?.billingBasis ?? 'unknown',
138
+ priceBookDigest: priceBook?.digest ?? null, model,
139
+ };
140
+ const entry = priceBook?.entries?.[model] ?? null;
141
+ const metered = priceBook && !UNMETERED_BASES.includes(priceBook.billingBasis);
142
+ const reason = !priceBook ? 'no price book' : !entry ? `no price for model '${model}'` : !metered ? `billing basis '${priceBook.billingBasis}' has no metered per-token cost` : null;
143
+ const usable = reason === null ? entry : null;
144
+
145
+ const u = usage || {};
146
+ const measuredUsage = u.source === 'measured';
147
+ const tokens = { inputTokens: pick(u.inputTokens) === null ? null : u.inputTokens, outputTokens: pick(u.outputTokens) === null ? null : u.outputTokens, cachedInputTokens: pick(u.cachedInputTokens) === null ? null : u.cachedInputTokens };
148
+ const unpriced = (why) => ({ input: unknown(why), cachedInput: unknown(why), output: unknown(why) });
149
+ let parts;
150
+ if (!usable) parts = unpriced(reason);
151
+ else if (!measuredUsage) parts = unpriced(`usage is '${u.source ?? 'unknown'}', not measured: a prediction is not substituted for a measurement`);
152
+ else parts = attemptCost(tokens, usable);
153
+
154
+ // retries: each earlier attempt, priced on its own reported usage, or bounded when it reported none
155
+ let retryParts;
156
+ if (!retries.length) retryParts = exact(0);
157
+ else {
158
+ const each = retries.map((r) => {
159
+ if (!usable) return unknown(reason);
160
+ if (r?.usage?.source === 'measured') { const c = attemptCost({ inputTokens: pick(r.usage.inputTokens) === null ? null : r.usage.inputTokens, outputTokens: pick(r.usage.outputTokens) === null ? null : r.usage.outputTokens, cachedInputTokens: pick(r.usage.cachedInputTokens) === null ? null : r.usage.cachedInputTokens }, usable); return sumParts([c.input, c.cachedInput.combinedWithInput ? exact(0) : c.cachedInput, c.output]); }
161
+ if (Number.isInteger(estimatedRequestTokens) && pick(usable.in) !== null) return bounded(0, (estimatedRequestTokens * usable.in) / PER, 'attempt reported no usage: bounded by its estimated input, never assumed free');
162
+ return unknown('attempt reported no usage and no request size estimate is available');
163
+ });
164
+ retryParts = sumParts(each);
165
+ }
166
+ const tool = toolExecutionUsd === undefined || toolExecutionUsd === null ? unknown('tool execution cost not measured') : (pick(toolExecutionUsd) !== null && toolExecutionUsd >= 0 ? exact(toolExecutionUsd) : unknown('invalid tool execution cost'));
167
+
168
+ const distinct = [parts.input, parts.cachedInput.combinedWithInput ? exact(0) : parts.cachedInput, parts.output, retryParts, tool];
169
+ const total = sumParts(distinct);
170
+ const measured = { ...parts, retries: retryParts, toolExecution: tool, total };
171
+ let predicted = null;
172
+ if (Number.isInteger(predictedTokens) && usable && pick(usable.in) !== null) {
173
+ predicted = { basis: 'prediction', upperUsd: (predictedTokens * usable.in) / PER, note: 'a prediction from a request-size estimate; it never replaces measured usage' };
174
+ }
175
+ // A cost on a metered basis from measured usage is "measured"; everything else is reported as it is.
176
+ return Object.freeze({ ...meta, measured, predicted, totalUsd: total.status === COST_STATUS.measured ? total.usd : null, totalStatus: total.status, totalLowerUsd: total.lowerUsd, totalUpperUsd: total.upperUsd });
177
+ }
178
+
179
+ /** Median of the known values; null (never 0) when there are none. */
180
+ export function medianKnown(values) {
181
+ const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b);
182
+ if (!v.length) return null;
183
+ const m = v.length >> 1;
184
+ return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
185
+ }