@swfte/nexus-sdk 0.1.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.
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ /**
3
+ * Exact model cost from real usage — a deliberate mirror of `nexus/integrations/pricing.py`, which
4
+ * is itself a deliberate mirror of `nexus_devtools/pricing.py`.
5
+ *
6
+ * Three copies of one rate card, and the copying is intentional rather than lazy: this SDK ships to
7
+ * customers under Apache-2.0 with **zero dependencies**, so it cannot import a module from the
8
+ * proprietary wrapper repo, and a network call to fetch rates on a request path is not a serious
9
+ * proposal. What keeps the copies honest is a parity check — `scripts/pricing-parity.mjs` loads the
10
+ * Python implementation and asserts identical dollars across a matrix that includes the cache
11
+ * split. If the rate card moves in one repo and not the others, that is the thing that fails.
12
+ *
13
+ * The property being defended is a product claim, not a rounding preference. "Exact cost" is what
14
+ * distinguishes this ledger from every observability tool that multiplies total tokens by a blended
15
+ * $/Mtok — and cache-heavy agent traffic is where that blend is *badly* wrong in both directions:
16
+ * cache reads are 10× cheaper than fresh input, cache writes 1.25–2× more expensive. A bridge that
17
+ * folds both into `input_tokens` reports a number the customer can disprove against their invoice,
18
+ * which is worse than reporting nothing.
19
+ *
20
+ * Unpriced models return `null` rather than a guess. A missing cost is a gap somebody can fill; an
21
+ * invented cost is a gap nobody can see.
22
+ */
23
+
24
+ /**
25
+ * `[matcher substring, input $/Mtok, output $/Mtok]`, ordered most-specific → least, so the first
26
+ * hit on a normalised id wins. Mirrors `_RATES` in both Python files verbatim.
27
+ *
28
+ * The `opus-5` row exists because `opus`'s catch-all was swallowing `claude-opus-5`. It returns
29
+ * what the catch-all already returned; the row is for parity, and for the day the catch-all's rate
30
+ * changes and this one should not.
31
+ */
32
+ const RATES = [
33
+ ['fable-5', 10.0, 50.0],
34
+ ['mythos-5', 10.0, 50.0],
35
+ ['opus-5', 5.0, 25.0],
36
+ ['opus-4-8', 5.0, 25.0],
37
+ ['opus-4-7', 5.0, 25.0],
38
+ ['opus-4-6', 5.0, 25.0],
39
+ ['opus-4-5', 5.0, 25.0],
40
+ ['opus-4-1', 15.0, 75.0],
41
+ ['opus-4-0', 15.0, 75.0],
42
+ ['opus-4', 15.0, 75.0],
43
+ ['3-opus', 15.0, 75.0],
44
+ ['opus', 5.0, 25.0],
45
+ ['sonnet-5', 3.0, 15.0],
46
+ ['sonnet-4-6', 3.0, 15.0],
47
+ ['sonnet-4-5', 3.0, 15.0],
48
+ ['sonnet-4', 3.0, 15.0],
49
+ ['3-7-sonnet', 3.0, 15.0],
50
+ ['3-5-sonnet', 3.0, 15.0],
51
+ ['sonnet', 3.0, 15.0],
52
+ ['haiku-4-5', 1.0, 5.0],
53
+ ['3-5-haiku', 0.80, 4.0],
54
+ ['3-haiku', 0.25, 1.25],
55
+ ['haiku', 1.0, 5.0],
56
+ ];
57
+
58
+ const CACHE_WRITE_5M = 1.25; // × input rate
59
+ const CACHE_WRITE_1H = 2.0; // × input rate
60
+ const CACHE_READ = 0.10; // × input rate
61
+
62
+ /**
63
+ * `cost_source` values. Provenance is part of the record: a figure derived from our rate card is an
64
+ * inference and must be legible as one next to a figure the provider itself reported.
65
+ */
66
+ const SOURCE_USAGE = 'usage'; // computed here from the provider's own token breakdown
67
+ const SOURCE_PROVIDER = 'provider'; // the provider or instrumentation reported dollars directly
68
+
69
+ /**
70
+ * Reduce a provider or span model id to its matchable core.
71
+ *
72
+ * The peel order is load-bearing because the decorations nest — a Bedrock id carries a regional
73
+ * prefix, a vendor prefix, a date *and* a model version at once:
74
+ *
75
+ * claude-haiku-4-5-20251001 → claude-haiku-4-5
76
+ * claude-fable-5[1m] → claude-fable-5
77
+ * us.anthropic.claude-haiku-4-5-v1:0 → claude-haiku-4-5
78
+ * bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 → claude-3-5-sonnet
79
+ * vertex_ai/claude-opus-4-8@20260101 → claude-opus-4-8
80
+ *
81
+ * Beyond pricing this keeps per-model aggregates whole: a serving route that normalised to a
82
+ * different string than the same weights reached first-party would split one model across two
83
+ * buckets and silently under-count every total.
84
+ */
85
+ function normalize(model) {
86
+ let m = String(model || '').toLowerCase().trim();
87
+ m = m.replace(/^[a-z][a-z0-9_-]*\//, ''); // bedrock/ , vertex_ai/ , <vendor>/ prefixes
88
+ m = m.replace(/^(?:us|eu|apac|global)\./, ''); // Bedrock cross-region inference prefixes
89
+ m = m.replace(/^anthropic\./, '');
90
+ m = m.replace(/\[[^\]]*\]/g, ''); // drop [1m] / [200k] context tags
91
+ m = m.replace(/-v\d+:\d+$/, ''); // drop Bedrock model-version suffix (-v1:0)
92
+ m = m.replace(/[@-]\d{8}$/, ''); // drop dated snapshot suffix
93
+ m = m.replace(/-fast$/, '');
94
+ return m;
95
+ }
96
+
97
+ /** `[input, output]` $/Mtok for a model, or `null` when unpriced. */
98
+ function rates(model) {
99
+ const norm = normalize(model);
100
+ for (const [needle, tin, tout] of RATES) {
101
+ if (norm.includes(needle)) return [tin, tout];
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function num(v) {
107
+ const n = typeof v === 'number' ? v : parseFloat(v);
108
+ return Number.isFinite(n) ? n : 0;
109
+ }
110
+
111
+ /**
112
+ * Exact USD for one usage breakdown, or `null` when the model is unpriced. Never throws.
113
+ *
114
+ * `usage` uses the transcript's own field names — `input_tokens` (uncached), `output_tokens`,
115
+ * `cache_read_input_tokens`, and either the split `cache_creation.ephemeral_5m_input_tokens` /
116
+ * `ephemeral_1h_input_tokens` or the flat `cache_creation_input_tokens` (priced at the 5-minute
117
+ * rate). See {@link costFromTokens} for the SDK's own camelCase field names.
118
+ */
119
+ function costFromUsage(model, usage) {
120
+ const r = rates(model);
121
+ if (!r) return null;
122
+ const [tin, tout] = r;
123
+ try {
124
+ const u = usage || {};
125
+ // `cache_creation` is whatever the caller put there. A bridge reading a hostile or half-written
126
+ // span can hand us a string, and reading a property off one would otherwise fall into the
127
+ // blanket catch below — turning one malformed field into a null cost for the whole call.
128
+ // Nothing about the other four numbers is unknowable just because this one is junk.
129
+ const cc = (u.cache_creation && typeof u.cache_creation === 'object') ? u.cache_creation : {};
130
+ let w5 = num(cc.ephemeral_5m_input_tokens);
131
+ const w1 = num(cc.ephemeral_1h_input_tokens);
132
+ if (!w5 && !w1) w5 = num(u.cache_creation_input_tokens); // no split → flat, treat as 5m
133
+
134
+ const dollars = (
135
+ num(u.input_tokens) * tin
136
+ + num(u.output_tokens) * tout
137
+ + num(u.cache_read_input_tokens) * tin * CACHE_READ
138
+ + w5 * tin * CACHE_WRITE_5M
139
+ + w1 * tin * CACHE_WRITE_1H
140
+ ) / 1000000;
141
+ // Python rounds to 6 decimal places, and matching it *exactly* matters: a fraction of a cent
142
+ // that differs between two producers turns "do these agree?" into a floating-point question
143
+ // rather than a data question.
144
+ //
145
+ // `toFixed(6)` rather than the obvious `Math.round(x * 1e6) / 1e6`. The two disagree, and the
146
+ // difference is not theoretical — it produced 9 mismatches across the parity matrix on the
147
+ // first run, every one an off-by-one in the sixth decimal place:
148
+ //
149
+ // 0.0004125 → Math.round 0.000413 toFixed 0.000412 (Python: 0.000412)
150
+ // 0.0000875 → Math.round 0.000088 toFixed 0.000087 (Python: 0.000087)
151
+ //
152
+ // Multiplying by 1e6 first introduces its own representation error and then rounds *that*,
153
+ // half away from zero. `toFixed` and CPython's `round()` both round the exact binary value of
154
+ // the double, which is why they agree on every case in the matrix including the ties.
155
+ return Number(dollars.toFixed(6));
156
+ } catch (_err) {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The same calculation over the field names this SDK uses on its own `usage()` call and in the AI
163
+ * bridge, rather than the transcript's snake_case ones.
164
+ *
165
+ * A thin adapter on purpose. The rate card and the arithmetic stay in one function that is
166
+ * diff-able against Python line by line; only the naming differs, and naming is exactly what a
167
+ * "port" gets wrong quietly. `cacheWriteTokens` maps to the flat `cache_creation_input_tokens` and
168
+ * is therefore priced at the 5-minute rate — neither the AI SDK nor `run.usage()` exposes the
169
+ * 5m/1h split, and inventing one would be a guess about a number the customer is billed for.
170
+ */
171
+ function costFromTokens(model, o) {
172
+ return costFromUsage(model, {
173
+ input_tokens: o.inputTokens,
174
+ output_tokens: o.outputTokens,
175
+ cache_read_input_tokens: o.cacheReadTokens,
176
+ cache_creation_input_tokens: o.cacheWriteTokens,
177
+ });
178
+ }
179
+
180
+ module.exports = {
181
+ RATES,
182
+ SOURCE_USAGE,
183
+ SOURCE_PROVIDER,
184
+ normalize,
185
+ rates,
186
+ costFromUsage,
187
+ costFromTokens,
188
+ };
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+ /**
3
+ * Where the running process thinks it came from — and, for every value, *how we know*.
4
+ *
5
+ * Port of `nexus-sdk/src/nexus/provenance.py`. Same detectors, same order, same field names, same
6
+ * refusals. A developer who has read one should not have to read the other.
7
+ *
8
+ * ── Why this file is separate, and why it refuses so much ────────────────────────────────────
9
+ *
10
+ * The operate plane exists to draw one chain honestly:
11
+ *
12
+ * repo ──► commit ──► build ──► deployment ──► running version
13
+ *
14
+ * and to draw a **break** wherever a link is missing (`docs/ANCHOR-INTEGRATION.md` §5.3). The
15
+ * break is the product: a dotted segment between "running version" and "deployment" is the
16
+ * shadow-deploy alarm — *software is running in production that no release accounts for*. That
17
+ * alarm is only meaningful if the SDK never invents a link.
18
+ *
19
+ * So this module has exactly two rules, and they are the two rules that matter most in the whole
20
+ * SDK:
21
+ *
22
+ * 1. **An absent value stays absent.** No `'unknown'`, no `''`, no `'HEAD'`, no zero. A key that
23
+ * is not known is not on the wire at all. `contract.base()` drops nulls for the same reason.
24
+ * 2. **Never guess silently.** Every detected value carries the name of the variable it was read
25
+ * from, as `provenance_source: "env:VERCEL_GIT_COMMIT_SHA"`. A value with no recorded source
26
+ * is a bug, and `detect()` cannot produce one — source and value are written together.
27
+ *
28
+ * Three consequences that are easy to undo by accident, all three copied from the Python module
29
+ * because each one is a place where a small convenience destroys the central claim:
30
+ *
31
+ * * **A partial value is an absent value.** `VERCEL_GIT_REPO_SLUG` is the bare repository name
32
+ * (`portal`). Publishing it as `repo` produces something that *looks* like a repo identity,
33
+ * joins against nothing in the devtools ledger, and therefore turns a drawn break into a
34
+ * drawn-but-wrong link. Half a join key joins to the wrong thing, not to nothing.
35
+ * * **`repo` never acquires a host we did not read.** Vercel publishes owner, slug and a provider
36
+ * *word* (`github`); it does not publish a host. Mapping `github` → `github.com` is right for
37
+ * the common case and wrong for every GitHub Enterprise install, and a wrong host silently
38
+ * matches nothing. So on Vercel `repo` is `owner/name`; on GitHub Actions, where
39
+ * `GITHUB_SERVER_URL` *is* published, it is `host/owner/name`. The consumer parses both forms;
40
+ * it cannot detect a fabricated segment.
41
+ * * **`application` is never inferred.** It is a declaration, not a similarity. The only
42
+ * auto-detected source is `app.kubernetes.io/name`, and that qualifies precisely because an
43
+ * operator wrote it down — we are relaying a declaration, not noticing a resemblance.
44
+ *
45
+ * ── What this module deliberately does not do ────────────────────────────────────────────────
46
+ *
47
+ * * **No `git rev-parse`.** Shelling out is synchronous process I/O at init, it reads the
48
+ * *filesystem's* commit rather than the *artifact's*, and in a container it usually finds no
49
+ * repository at all. A commit read from a working tree that happens to be mounted next to the
50
+ * process is a guess wearing a fact's clothes.
51
+ * * **No talking to a container runtime.** OCI labels live in the image manifest and a process
52
+ * cannot read its own image labels without a daemon socket. What it can read is the convention
53
+ * of projecting them into the environment at build time, which is what `_oci` does — and if a
54
+ * deployment does not do that, the value is absent, which is the correct answer.
55
+ * * **No normalisation of `env`.** `VERCEL_ENV=production` is passed through as `production`, not
56
+ * rewritten to `prod`. Rewriting is an unsourced translation of a value we were told; the
57
+ * console's own mapping is a mapping it can see and change.
58
+ *
59
+ * The one file read is Kubernetes' downward-API labels projection, exactly as the Python module
60
+ * does it: bounded to 128 lines, wrapped so a malformed byte from a sidecar cannot be why `init()`
61
+ * raised, and attempted only at start-up. Nothing here runs on a request path.
62
+ */
63
+
64
+ const fs = require('node:fs');
65
+ const path = require('node:path');
66
+
67
+ /** Cap on any single value. Provenance values are identifiers; a 4 kB one is a bug or an attack. */
68
+ const MAX = 256;
69
+
70
+ /**
71
+ * The fields this module can fill, in the order the console's ribbon reads them. Snake_case
72
+ * because these are wire keys — `provenance` ships as `{ deployment_id: 'env:…' }`, and a map
73
+ * whose keys differ between the Python and Node producers would be two vocabularies.
74
+ */
75
+ const FIELDS = ['application', 'repo', 'commit', 'env', 'version', 'branch', 'deployment_id'];
76
+
77
+ /** Trim, cap, and treat empty-after-trim as absent. `FOO=` in a base image must not become a value. */
78
+ function clean(v) {
79
+ if (v === undefined || v === null) return null;
80
+ const s = String(v).trim();
81
+ return s === '' ? null : s.slice(0, MAX);
82
+ }
83
+
84
+ /** First non-empty variable in `names`, and the name that supplied it. */
85
+ function get(env, names) {
86
+ for (const n of names) {
87
+ const v = clean(env[n]);
88
+ if (v !== null) return [v, 'env:' + n];
89
+ }
90
+ return [null, null];
91
+ }
92
+
93
+ /**
94
+ * Record a value and its source. First writer wins; an absent value writes nothing at all — not a
95
+ * `null` entry, because a key present with a null value claims we looked and found emptiness,
96
+ * which is a different fact from not having looked.
97
+ */
98
+ function put(out, src, field, value, source) {
99
+ if (value === null || value === undefined || out[field] !== undefined) return;
100
+ out[field] = value;
101
+ src[field] = source;
102
+ }
103
+
104
+ /** A platform answered *part* of a field. Recorded so a customer can be told why the ribbon broke. */
105
+ function partial(state, field) {
106
+ if (state.incomplete.indexOf(field) === -1) state.incomplete.push(field);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------------------------
110
+ // platform detectors — each reads only, and reports what it read
111
+ // ---------------------------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Vercel build and runtime environment.
115
+ *
116
+ * Gated on `VERCEL` / `VERCEL_ENV`: every Vercel runtime sets one of them, and without that gate
117
+ * the `VERCEL_*` names could be anything a customer happened to export — reading them would be a
118
+ * guess about which platform we are on.
119
+ */
120
+ function vercel(env, out, src, state) {
121
+ if (!clean(env.VERCEL) && !clean(env.VERCEL_ENV)) return;
122
+
123
+ const [commit, cs] = get(env, ['VERCEL_GIT_COMMIT_SHA']);
124
+ put(out, src, 'commit', commit, cs);
125
+
126
+ const owner = clean(env.VERCEL_GIT_REPO_OWNER);
127
+ const slug = clean(env.VERCEL_GIT_REPO_SLUG);
128
+ if (owner && slug) {
129
+ // No host. See the module docstring: `VERCEL_GIT_PROVIDER` is a word, not a hostname.
130
+ put(out, src, 'repo', owner + '/' + slug, 'env:VERCEL_GIT_REPO_OWNER+VERCEL_GIT_REPO_SLUG');
131
+ } else if (owner || slug) {
132
+ partial(state, 'repo');
133
+ }
134
+
135
+ const [branch, bs] = get(env, ['VERCEL_GIT_COMMIT_REF']);
136
+ put(out, src, 'branch', branch, bs);
137
+
138
+ const [dep, ds] = get(env, ['VERCEL_DEPLOYMENT_ID']);
139
+ put(out, src, 'deployment_id', dep, ds);
140
+
141
+ // Verbatim: "production" / "preview" / "development". Mapping those onto the console's
142
+ // prod|staging|dev|preview vocabulary is the consumer's job.
143
+ const [venv, es] = get(env, ['VERCEL_ENV']);
144
+ put(out, src, 'env', venv, es);
145
+ }
146
+
147
+ /** GitHub Actions. The only platform here that publishes its own host. */
148
+ function githubActions(env, out, src, state) {
149
+ if (!clean(env.GITHUB_ACTIONS)) return;
150
+
151
+ const [commit, cs] = get(env, ['GITHUB_SHA']);
152
+ put(out, src, 'commit', commit, cs);
153
+
154
+ const repo = clean(env.GITHUB_REPOSITORY); // "owner/name"
155
+ if (repo && repo.indexOf('/') !== -1) {
156
+ const server = clean(env.GITHUB_SERVER_URL);
157
+ const host = server ? server.split('://').pop().replace(/\/+$/, '') : '';
158
+ if (host) {
159
+ put(out, src, 'repo', host + '/' + repo, 'env:GITHUB_SERVER_URL+GITHUB_REPOSITORY');
160
+ } else {
161
+ put(out, src, 'repo', repo, 'env:GITHUB_REPOSITORY');
162
+ }
163
+ } else if (repo) {
164
+ partial(state, 'repo');
165
+ }
166
+
167
+ const [branch, bs] = get(env, ['GITHUB_REF_NAME']);
168
+ put(out, src, 'branch', branch, bs);
169
+ }
170
+
171
+ /**
172
+ * The recommended Kubernetes labels, from projected env or from the projected labels file.
173
+ *
174
+ * This is the *only* auto-detected source of `application`, and it qualifies because
175
+ * `app.kubernetes.io/name` is something an operator wrote down.
176
+ */
177
+ function kubernetes(env, out, src) {
178
+ const [name, ns] = get(env, ['APP_KUBERNETES_IO_NAME', 'K8S_APP_NAME']);
179
+ put(out, src, 'application', name, ns);
180
+ const [ver, vs] = get(env, ['APP_KUBERNETES_IO_VERSION', 'K8S_APP_VERSION']);
181
+ put(out, src, 'version', ver, vs);
182
+
183
+ if (out.application !== undefined && out.version !== undefined) return;
184
+
185
+ const dir = clean(env.NEXUS_PODINFO_DIR) || '/etc/podinfo';
186
+ const where = path.join(dir, 'labels');
187
+ const labels = readLabels(where);
188
+ if (!labels) return;
189
+ put(out, src, 'application', clean(labels['app.kubernetes.io/name']),
190
+ 'file:' + where + '#app.kubernetes.io/name');
191
+ put(out, src, 'version', clean(labels['app.kubernetes.io/version']),
192
+ 'file:' + where + '#app.kubernetes.io/version');
193
+ }
194
+
195
+ /**
196
+ * Parse a downward-API labels projection: one `key="value"` per line.
197
+ *
198
+ * Bounded and total. A projected volume that is not mounted is the normal case rather than an
199
+ * error, and a malformed line is skipped rather than raised on — this runs inside `init()`, and
200
+ * `init()` throwing because a sidecar wrote a stray byte would be the SDK becoming the outage.
201
+ */
202
+ function readLabels(file) {
203
+ try {
204
+ if (!fs.existsSync(file)) return null;
205
+ const text = fs.readFileSync(file, 'utf8');
206
+ const out = {};
207
+ const lines = text.split('\n');
208
+ for (let i = 0; i < lines.length && i < 128; i += 1) {
209
+ const line = lines[i].trim();
210
+ const eq = line.indexOf('=');
211
+ if (eq === -1) continue;
212
+ out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"|"$/g, '').slice(0, MAX);
213
+ }
214
+ return out;
215
+ } catch (_) {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /** Generic OCI image labels, projected into the environment at build time. */
221
+ function oci(env, out, src) {
222
+ const [commit, cs] = get(env, [
223
+ 'OCI_IMAGE_REVISION', 'ORG_OPENCONTAINERS_IMAGE_REVISION', 'org.opencontainers.image.revision',
224
+ ]);
225
+ put(out, src, 'commit', commit, cs);
226
+
227
+ const [source, ss] = get(env, [
228
+ 'OCI_IMAGE_SOURCE', 'ORG_OPENCONTAINERS_IMAGE_SOURCE', 'org.opencontainers.image.source',
229
+ ]);
230
+ if (source) {
231
+ // The label is a URL by convention. Stripping the scheme and a trailing ".git" normalises a
232
+ // value we were given; it does not infer one we were not.
233
+ let repo = source.split('://').pop().replace(/\/+$/, '');
234
+ if (repo.endsWith('.git')) repo = repo.slice(0, -4);
235
+ put(out, src, 'repo', repo || null, ss);
236
+ }
237
+
238
+ const [ver, vs] = get(env, [
239
+ 'OCI_IMAGE_VERSION', 'ORG_OPENCONTAINERS_IMAGE_VERSION', 'org.opencontainers.image.version',
240
+ ]);
241
+ put(out, src, 'version', ver, vs);
242
+ }
243
+
244
+ const DETECTORS = [vercel, githubActions, kubernetes, oci];
245
+
246
+ /**
247
+ * Detect provenance, recording where every value came from.
248
+ *
249
+ * @param {Record<string, unknown>} [explicit] values the application passed to `init()`, already
250
+ * merged with the `NEXUS_*` variables by the caller — which is why anything present here is
251
+ * stamped `explicit`. "The application told us" is a *stronger* claim than "we read it off the
252
+ * platform", not a weaker one, so it short-circuits detection for that field.
253
+ * @param {Record<string, string|undefined>} [env] defaults to `process.env`; injectable so the
254
+ * tests can exercise a clean environment without mutating the process.
255
+ * @returns {{values: Record<string,string>, sources: Record<string,string>, incomplete: string[]}}
256
+ * `values` and `sources` always have exactly the same key set — a value can never arrive without
257
+ * its source. `incomplete` names fields a platform *nearly* answered, so the SDK can count them
258
+ * and a customer whose ribbon has a break can be told which kind of break it is.
259
+ */
260
+ function detect(explicit, env) {
261
+ const e = env || process.env;
262
+ const values = {};
263
+ const sources = {};
264
+ const state = { incomplete: [] };
265
+
266
+ for (const field of FIELDS) {
267
+ const given = clean(explicit ? explicit[field] : null);
268
+ if (given === null) continue;
269
+ values[field] = given;
270
+ // ── `env` and `version` take a value but never a source, matching `provenance.py:resolve` ──
271
+ //
272
+ // They are unified tags the process already carries, with their own `"unknown"` sentinel, and
273
+ // they appear in FIELDS only so that a *detected* value can beat that sentinel. Stamping them
274
+ // `'explicit'` makes the sources map non-empty on every ordinary `init()` — so a console
275
+ // reading "does this process have provenance?" off a non-empty map sees provenance everywhere
276
+ // and a break nowhere, which is the one question the ribbon exists to answer.
277
+ //
278
+ // The Python SDK reached this first and says so in its own comment. This SDK stamped both and
279
+ // therefore published a sources map two keys wider than Python's for the same `init()` call.
280
+ if (field === 'env' || field === 'version') continue;
281
+ sources[field] = 'explicit';
282
+ }
283
+
284
+ for (const fn of DETECTORS) {
285
+ // A detector must never be the reason `init()` failed.
286
+ try { fn(e, values, sources, state); } catch (_) { /* next platform */ }
287
+ }
288
+
289
+ return { values, sources, incomplete: state.incomplete };
290
+ }
291
+
292
+ /**
293
+ * The scalar the console's `RunningVersion.provenance_source` expects.
294
+ *
295
+ * It is the source of **the commit**, and of nothing else. The commit is the join key into the
296
+ * commit-anchored devtools ledger; a ribbon printing the source of some *other* field while the
297
+ * commit is absent would attach a provenance claim to a link that does not exist. Absent when the
298
+ * commit is absent, which is exactly when the ribbon should draw a break.
299
+ */
300
+ function primarySource(sources) {
301
+ return (sources && sources.commit) || null;
302
+ }
303
+
304
+ module.exports = { detect, primarySource, FIELDS, MAX, readLabels };