@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.
- package/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +414 -0
- package/ai.d.ts +84 -0
- package/index.d.ts +433 -0
- package/otel.d.ts +80 -0
- package/package.json +93 -0
- package/policy.d.ts +141 -0
- package/src/ai.cjs +334 -0
- package/src/ai.js +39 -0
- package/src/core.cjs +2411 -0
- package/src/health.cjs +172 -0
- package/src/index.cjs +53 -0
- package/src/index.js +151 -0
- package/src/otel/bridge.cjs +257 -0
- package/src/otel/classify.cjs +166 -0
- package/src/otel/index.cjs +84 -0
- package/src/otel/index.js +39 -0
- package/src/otel/semconv.cjs +650 -0
- package/src/policy/engine.cjs +368 -0
- package/src/policy/envelope.cjs +256 -0
- package/src/policy/index.cjs +224 -0
- package/src/policy/rules.cjs +442 -0
- package/src/pricing.cjs +188 -0
- package/src/provenance.cjs +304 -0
- package/src/redact.cjs +734 -0
package/src/pricing.cjs
ADDED
|
@@ -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 };
|