@edgehero/pi-dispatch 2.1.0 → 3.0.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/.env.example +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +8 -1
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2261 -203
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +107 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +135 -7
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one spelling of "what a model or provider id may look like" in a record (issues #501, #502).
|
|
3
|
+
*
|
|
4
|
+
* PURE and import-free, on purpose: `triggers.mjs` is the shared validator the receiver loads and the admin
|
|
5
|
+
* bundle inlines, and the model-reference checks that land on top of this (a trigger's `run.provider` and
|
|
6
|
+
* `run.model`, an allowed-model list) must be importable there without dragging anything in.
|
|
7
|
+
*
|
|
8
|
+
* Applied AFTER lowercasing, as the run record's ledger always has (`parseExitUsage`), so the pattern never
|
|
9
|
+
* has to admit uppercase.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT WIDENED. The ledger's old pattern, `^[a-z0-9][a-z0-9._:/-]{0,63}$`, refused real catalog ids, and
|
|
12
|
+
* one refused row nulls the whole `usage` block, so a job on such a model recorded no ledger at all. Measured
|
|
13
|
+
* on 2026-10-02 against pi-ai 0.99.1's builtin catalog (`getAllBuiltinModels` over all 42 providers, chat,
|
|
14
|
+
* image and classifier models, 1,589 ids): the old pattern refused 55. Two shapes:
|
|
15
|
+
* - 37 contain an `@` or start with `~`: `cloudflare-ai-gateway`'s `workers-ai/@cf/...` and openrouter's
|
|
16
|
+
* `~vendor/model` aliases;
|
|
17
|
+
* - 18 START with `@`: `cloudflare-workers-ai`'s own `@cf/vendor/model` ids.
|
|
18
|
+
* Ollama tags (`qwen2.5:0.5b-instruct-q4_K_M`) carry `_`, which it refused too. This pattern admits all 1,589
|
|
19
|
+
* builtin ids and every provider id. The longest builtin id is 56 characters, inside the 64 cap.
|
|
20
|
+
*
|
|
21
|
+
* What it still refuses, and why that is kept: a first character (after one optional `~` or `@`) that is
|
|
22
|
+
* not a letter or digit, so `.hidden`, `../etc`, `/abs` and `:x` fail at character one, as before; any
|
|
23
|
+
* character outside the class (space, quote, backslash, control bytes); and anything over 64 characters.
|
|
24
|
+
*/
|
|
25
|
+
export const MODEL_REF_PATTERN = /^(?=.{1,64}$)[~@]?[a-z0-9][a-z0-9._:/@_-]*$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A trigger's provider id (issue #502). Narrower than a model id: no `/`, `:` or `@` and no leading `~`.
|
|
29
|
+
* Those characters exist in MODEL ids (`@cf/...`, `openrouter/~...`, Ollama `name:tag`), never in a provider
|
|
30
|
+
* id, and an allowed-model entry (`provider/model`) splits at the first `/`, so a provider carrying one could
|
|
31
|
+
* never be listed. Applied after lowercasing, like `MODEL_REF_PATTERN`.
|
|
32
|
+
*/
|
|
33
|
+
export const PROVIDER_REF_PATTERN = /^(?=.{1,64}$)[a-z0-9][a-z0-9._-]*$/;
|
|
34
|
+
/**
|
|
35
|
+
* Validate the optional `run.provider`, `run.model`, `run.maxTurns` and `run.models` of one trigger (issue #502) and return
|
|
36
|
+
* the fields that were present, so a normalizer can spread them and an absent field stays absent. Shared by
|
|
37
|
+
* the trigger loader and the console, so both refuse with the same words.
|
|
38
|
+
*
|
|
39
|
+
* A model is checked against `MODEL_REF_PATTERN`, the ledger's own pattern, so any model a trigger can name
|
|
40
|
+
* is one whose usage row the host keeps. The original case is what the job runs with, because pi matches
|
|
41
|
+
* model ids case-sensitively; only the CHECK lowercases.
|
|
42
|
+
*
|
|
43
|
+
* Errors name the trigger and the key and never echo the value: an operator-typed id is not secret, but the
|
|
44
|
+
* console renders this message to a model, and a key name is all an operator needs to find the line.
|
|
45
|
+
*/
|
|
46
|
+
export function validateModelRef(run, at, path) {
|
|
47
|
+
// `configError`'s exact shape (config.mjs), built here rather than imported so this module stays
|
|
48
|
+
// import-free: run-history.mjs imports it too, and must not drag config.mjs's fs and os in with it.
|
|
49
|
+
const configError = (message) => Object.assign(new Error(message), { piDispatchConfig: true });
|
|
50
|
+
const out = {};
|
|
51
|
+
// ASCII is checked on the ORIGINAL before lowercasing, because lowercasing is not closed over ASCII:
|
|
52
|
+
// the Kelvin sign (U+212A) lowercases to a plain `k`, so a lowercase-then-match check alone would pass
|
|
53
|
+
// a homoglyph that then reaches the job verbatim and names a model nobody listed.
|
|
54
|
+
const ascii = (value) => /^[\x21-\x7E]+$/.test(value);
|
|
55
|
+
// NULL IS ABSENT, for all three. Before #502 a hand-written cron entry's `"model": null` loaded and
|
|
56
|
+
// meant the deployment default (the worker fills `job.data ?? overlay ?? env`, and `??` passes over
|
|
57
|
+
// null), so refusing it now would turn a working file into one that refuses to load for no gain.
|
|
58
|
+
if (run?.provider != null) {
|
|
59
|
+
const provider = run.provider;
|
|
60
|
+
if (typeof provider !== "string" || !ascii(provider) || !PROVIDER_REF_PATTERN.test(provider.toLowerCase())) {
|
|
61
|
+
throw configError(`${at}: run.provider must be a provider id of 1 to 64 characters: letters, digits, dot, dash and underscore, starting with a letter or digit: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
out.provider = provider;
|
|
64
|
+
}
|
|
65
|
+
if (run?.model != null) {
|
|
66
|
+
const model = run.model;
|
|
67
|
+
if (typeof model !== "string" || !ascii(model) || !MODEL_REF_PATTERN.test(model.toLowerCase())) {
|
|
68
|
+
throw configError(`${at}: run.model must be a model id of 1 to 64 characters: letters, digits and . _ - : / @, starting with a letter or digit (or ~ or @ then one): ${path}`);
|
|
69
|
+
}
|
|
70
|
+
out.model = model;
|
|
71
|
+
}
|
|
72
|
+
if (run?.maxTurns != null) {
|
|
73
|
+
if (!Number.isSafeInteger(run.maxTurns) || run.maxTurns < 1) {
|
|
74
|
+
throw configError(`${at}: run.maxTurns must be a positive integer: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
out.maxTurns = run.maxTurns;
|
|
77
|
+
}
|
|
78
|
+
// `run.models` (issue #502): the models this trigger's job may call. Null is absent, the rule above.
|
|
79
|
+
if (run?.models != null) {
|
|
80
|
+
const problem = modelListProblem(run.models);
|
|
81
|
+
if (problem !== null) throw configError(`${at}: run.models ${problem}: ${path}`);
|
|
82
|
+
// The main model must be on its own list, refused HERE when the trigger names all three, because the
|
|
83
|
+
// file alone answers it. When the provider or the model comes from the deployment default instead,
|
|
84
|
+
// the worker answers it at job start (`model-not-allowed`, pre-spend).
|
|
85
|
+
if (out.provider !== undefined && out.model !== undefined && !modelOnList(run.models, out.provider, out.model)) {
|
|
86
|
+
throw configError(`${at}: run.models does not list this trigger's own run.provider/run.model, so every job it starts would be refused: ${path}`);
|
|
87
|
+
}
|
|
88
|
+
out.models = [...run.models];
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The most entries an allowed-model list may carry (issue #502). Past this a list is a catalog, not a policy. */
|
|
94
|
+
export const MAX_ALLOWED_MODELS = 16;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One allowed-model entry, `provider/model`, split at the FIRST `/` (issue #502): a provider id carries no `/`
|
|
98
|
+
* while a model id may (`openrouter`'s `vendor/model`, cloudflare's `@cf/vendor/model`). The runner splits the
|
|
99
|
+
* same way (`image/runner/src/config.mjs`). Each half passes the same rule `run.provider` and `run.model` do,
|
|
100
|
+
* ASCII checked before lowercasing for the reason `validateModelRef` gives. Returns `{ provider, model }` in
|
|
101
|
+
* the ORIGINAL case, or `null` when the entry is malformed.
|
|
102
|
+
*/
|
|
103
|
+
export function splitModelEntry(entry) {
|
|
104
|
+
if (typeof entry !== "string" || !/^[\x21-\x7E]+$/.test(entry)) return null;
|
|
105
|
+
const slash = entry.indexOf("/");
|
|
106
|
+
if (slash <= 0) return null;
|
|
107
|
+
const provider = entry.slice(0, slash);
|
|
108
|
+
const model = entry.slice(slash + 1);
|
|
109
|
+
if (!PROVIDER_REF_PATTERN.test(provider.toLowerCase()) || !MODEL_REF_PATTERN.test(model.toLowerCase())) return null;
|
|
110
|
+
return { provider, model };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* An allowed-model list (issue #502), from a trigger's `run.models` or the deployment's `PI_ALLOWED_MODELS`:
|
|
115
|
+
* the problem as a sentence, or null when the list is good. ONE rule for both sources, so a list an operator
|
|
116
|
+
* moves from a trigger into the env (or back) means the same thing in both places.
|
|
117
|
+
* - 1 to 16 entries. EMPTY is refused, never read as "unrestricted" or as "nothing allowed": the first fails
|
|
118
|
+
* open, the second refuses every call of a job whose operator meant something else, and an empty list in a
|
|
119
|
+
* reviewed file is more likely a template bug than either. Absent (or null) is how a list says "none".
|
|
120
|
+
* - every entry `provider/model`, see `splitModelEntry`.
|
|
121
|
+
* - no duplicates, compared CASE-INSENSITIVELY. Matching at the runner is exact, so `openai/GPT-x` beside
|
|
122
|
+
* `openai/gpt-x` is at best a dead entry and at worst the one that was meant; either way it is a typo the
|
|
123
|
+
* file should not keep, and the run record's ledger lowercases ids, where the two would be one row.
|
|
124
|
+
* The entry text is never echoed: the position is, and the console renders this message to a model.
|
|
125
|
+
*/
|
|
126
|
+
export function modelListProblem(list) {
|
|
127
|
+
if (!Array.isArray(list)) return "must be an array of provider/model strings";
|
|
128
|
+
if (list.length === 0) return "must not be empty (leave it out for no restriction)";
|
|
129
|
+
if (list.length > MAX_ALLOWED_MODELS) return `must have at most ${MAX_ALLOWED_MODELS} entries`;
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
for (let i = 0; i < list.length; i++) {
|
|
132
|
+
if (splitModelEntry(list[i]) === null) return `entry ${i + 1} must be provider/model: a provider id, a slash, then a model id (each 1 to 64 characters, no spaces)`;
|
|
133
|
+
const key = list[i].toLowerCase();
|
|
134
|
+
if (seen.has(key)) return `entry ${i + 1} repeats an earlier entry (compared ignoring case)`;
|
|
135
|
+
seen.add(key);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Is `provider/model` on the list? EXACT and case-sensitive, the runner guard's rule, because pi resolves model
|
|
142
|
+
* ids case-sensitively: an entry differing only in case names a model pi would not pick. Both halves are
|
|
143
|
+
* compared, so a list naming `openai/x` does not admit `azure-openai-responses/x`.
|
|
144
|
+
*/
|
|
145
|
+
export function modelOnList(list, provider, model) {
|
|
146
|
+
if (!Array.isArray(list)) return true;
|
|
147
|
+
return list.some((entry) => {
|
|
148
|
+
const ref = splitModelEntry(entry);
|
|
149
|
+
return ref !== null && ref.provider === provider && ref.model === model;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay `models.json`, read the way pi reads it (issue #502, PR #536's review): pi 0.99.1's
|
|
3
|
+
* `ModelConfig.load` (`pi-coding-agent/dist/core/model-config.js`) strips a leading BOM, strips `//` comments and
|
|
4
|
+
* trailing commas outside strings, parses, and then checks the whole document against its TypeBox schema. ANY
|
|
5
|
+
* schema error drops the WHOLE file: pi then knows none of its providers or models.
|
|
6
|
+
*
|
|
7
|
+
* The worker must agree with that verdict in both directions, because it decides with it before any spend:
|
|
8
|
+
* - a file pi accepts (comments, a BOM, a trailing comma) must not be refused here, or a working overlay model
|
|
9
|
+
* reads as unknown;
|
|
10
|
+
* - a file pi drops (a `contextWindow: "big"` on some other provider) must not be accepted here, or the job is
|
|
11
|
+
* admitted, reserves, starts a container, and the runner exits 2 on a model pi never loaded.
|
|
12
|
+
*
|
|
13
|
+
* MIRRORED, not imported: the worker depends on pi-ai only and does not import pi-coding-agent, and the module is
|
|
14
|
+
* not an exported path of that package. The mirror is held to pi by a DIFFERENTIAL test
|
|
15
|
+
* (`worker/test/models-json.test.mjs`) that runs pi's own `ModelConfig.load` at the pin over a corpus of valid and
|
|
16
|
+
* mutated files and requires the same verdict on every one, so a pi bump that moves the schema fails there.
|
|
17
|
+
*
|
|
18
|
+
* Pure: text in, `{ value }` or `{ error }` out. The fs read stays in `readOverlayModels` (model-endpoints.mjs).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** pi's `stripBom` (`utils/text.js`): one leading U+FEFF. */
|
|
22
|
+
export function stripBom(text) {
|
|
23
|
+
return text.startsWith("\uFEFF") ? text.slice(1) : text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* pi's `stripJsonComments` (`utils/json.js`): `//` line comments and trailing commas, strings untouched. pi writes it
|
|
28
|
+
* as two global regex replaces (a string or a `//` comment, then a string or a comma before `}` or `]`), which
|
|
29
|
+
* are QUADRATIC on an unterminated string (PR #546's review: `'"' + '\\"'.repeat(n)` at 160 KB took 13 s on the
|
|
30
|
+
* worker's event loop, once per job). This is the same function as one linear scan per pass, held to pi's own output
|
|
31
|
+
* by a differential test over the corpus and adversarial cases (`models-json.test.mjs`).
|
|
32
|
+
*
|
|
33
|
+
* How it stays linear and exact. At a `"` the regex tries a string: escapes `\` plus any one code unit but a line
|
|
34
|
+
* terminator (`.` without the `s` flag), other code units but `"` and `\`, up to the first unescaped `"`. When that
|
|
35
|
+
* fails (end of text, or a `\` before a line terminator or at the end), the regex moves ONE code unit on. Every `"`
|
|
36
|
+
* up to the failure point is then an escaped one in the failed attempt's reading, so a string tried there reads the
|
|
37
|
+
* same code units from the next boundary on and fails at the same point. Those starts are skipped, not retried.
|
|
38
|
+
*/
|
|
39
|
+
const isLineTerminator = (c) => c === "\n" || c === "\r" || c === "\u2028" || c === "\u2029";
|
|
40
|
+
|
|
41
|
+
/** Where the string opened at `start` closes (the index of its closing `"`), else `{ failAt }`. */
|
|
42
|
+
function stringEnd(input, start) {
|
|
43
|
+
let j = start + 1;
|
|
44
|
+
while (j < input.length) {
|
|
45
|
+
const c = input[j];
|
|
46
|
+
if (c === '"') return { end: j };
|
|
47
|
+
if (c === "\\") {
|
|
48
|
+
if (j + 1 >= input.length || isLineTerminator(input[j + 1])) return { failAt: j };
|
|
49
|
+
j += 2;
|
|
50
|
+
} else j += 1;
|
|
51
|
+
}
|
|
52
|
+
return { failAt: input.length };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One pass: strings kept, and at any other code unit `other(i)` returns `[text to emit, next index]` or null. */
|
|
56
|
+
function scanPass(input, other) {
|
|
57
|
+
let out = "";
|
|
58
|
+
let i = 0;
|
|
59
|
+
let failedUntil = -1; // a `"` at or before this index cannot open a string (see above)
|
|
60
|
+
while (i < input.length) {
|
|
61
|
+
if (input[i] === '"' && i > failedUntil) {
|
|
62
|
+
const r = stringEnd(input, i);
|
|
63
|
+
if (r.end !== undefined) {
|
|
64
|
+
out += input.slice(i, r.end + 1);
|
|
65
|
+
i = r.end + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
failedUntil = r.failAt;
|
|
69
|
+
}
|
|
70
|
+
const step = input[i] === '"' ? null : other(i);
|
|
71
|
+
if (step === null) {
|
|
72
|
+
out += input[i];
|
|
73
|
+
i += 1;
|
|
74
|
+
} else {
|
|
75
|
+
out += step[0];
|
|
76
|
+
i = step[1];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isJsSpace = (c) => /\s/.test(c);
|
|
83
|
+
|
|
84
|
+
export function stripJsonComments(input) {
|
|
85
|
+
const noComments = scanPass(input, (i) => {
|
|
86
|
+
if (input[i] !== "/" || input[i + 1] !== "/") return null;
|
|
87
|
+
let j = i + 2;
|
|
88
|
+
while (j < input.length && input[j] !== "\n") j += 1;
|
|
89
|
+
return ["", j];
|
|
90
|
+
});
|
|
91
|
+
return scanPass(noComments, (i) => {
|
|
92
|
+
if (noComments[i] !== ",") return null;
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < noComments.length && isJsSpace(noComments[j])) j += 1;
|
|
95
|
+
if (noComments[j] !== "}" && noComments[j] !== "]") return null;
|
|
96
|
+
return [noComments.slice(i + 1, j + 1), j + 1];
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A minimal interpreter for the TypeBox kinds pi's schema uses. TypeBox's defaults, as compiled at the pin: an
|
|
101
|
+
// Object admits extra keys and refuses arrays and null; a Number is a finite number; a Record checks every own value
|
|
102
|
+
// whose key has no line terminator.
|
|
103
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
104
|
+
const T = {
|
|
105
|
+
str: ({ minLength = 0 } = {}) => (v) => typeof v === "string" && v.length >= minLength,
|
|
106
|
+
num: ({ exclusiveMinimum } = {}) => (v) => typeof v === "number" && Number.isFinite(v) && (exclusiveMinimum === undefined || v > exclusiveMinimum),
|
|
107
|
+
int: ({ minimum, maximum } = {}) => (v) => Number.isInteger(v) && (minimum === undefined || v >= minimum) && (maximum === undefined || v <= maximum),
|
|
108
|
+
bool: () => (v) => typeof v === "boolean",
|
|
109
|
+
lit: (x) => (v) => v === x,
|
|
110
|
+
nul: () => (v) => v === null,
|
|
111
|
+
unknown: () => () => true,
|
|
112
|
+
union: (...xs) => (v) => xs.some((x) => x(v)),
|
|
113
|
+
arr: (x, { maxItems } = {}) => (v) => Array.isArray(v) && (maxItems === undefined || v.length <= maxItems) && v.every((e) => x(e)),
|
|
114
|
+
// TypeBox compiles `Record(String, X)` to `patternProperties: { "^.*$": X }`, and `.` matches no line terminator, so a
|
|
115
|
+
// key holding `\n`, `\r`, U+2028 or U+2029 matches no pattern and its value is never checked (PR #536's review,
|
|
116
|
+
// measured against pi at the pin). Mirrored: such a key's value passes, as it does for pi.
|
|
117
|
+
rec: (x) => (v) => isObj(v) && Object.entries(v).every(([k, e]) => !/^.*$/.test(k) || x(e)),
|
|
118
|
+
// `props` maps a key to [check, optional]. A required key must be present.
|
|
119
|
+
obj: (props) => (v) => isObj(v) && Object.entries(props).every(([k, [check, optional]]) => (Object.hasOwn(v, k) ? check(v[k]) : optional === true)),
|
|
120
|
+
};
|
|
121
|
+
const opt = (check) => [check, true];
|
|
122
|
+
const req = (check) => [check, false];
|
|
123
|
+
|
|
124
|
+
// The schema, transcribed from model-config.js at the 0.99.1 pin, in its order.
|
|
125
|
+
const PercentileCutoffs = T.obj({ p50: opt(T.num()), p75: opt(T.num()), p90: opt(T.num()), p99: opt(T.num()) });
|
|
126
|
+
const numOrStr = T.union(T.num(), T.str());
|
|
127
|
+
const OpenRouterRouting = T.obj({
|
|
128
|
+
allow_fallbacks: opt(T.bool()),
|
|
129
|
+
require_parameters: opt(T.bool()),
|
|
130
|
+
data_collection: opt(T.union(T.lit("deny"), T.lit("allow"))),
|
|
131
|
+
zdr: opt(T.bool()),
|
|
132
|
+
enforce_distillable_text: opt(T.bool()),
|
|
133
|
+
order: opt(T.arr(T.str())),
|
|
134
|
+
only: opt(T.arr(T.str())),
|
|
135
|
+
ignore: opt(T.arr(T.str())),
|
|
136
|
+
quantizations: opt(T.arr(T.str())),
|
|
137
|
+
sort: opt(T.union(T.str(), T.obj({ by: opt(T.str()), partition: opt(T.union(T.str(), T.nul())) }))),
|
|
138
|
+
max_price: opt(T.obj({ prompt: opt(numOrStr), completion: opt(numOrStr), image: opt(numOrStr), audio: opt(numOrStr), request: opt(numOrStr) })),
|
|
139
|
+
preferred_min_throughput: opt(T.union(T.num(), PercentileCutoffs)),
|
|
140
|
+
preferred_max_latency: opt(T.union(T.num(), PercentileCutoffs)),
|
|
141
|
+
});
|
|
142
|
+
const VercelGatewayRouting = T.obj({ only: opt(T.arr(T.str())), order: opt(T.arr(T.str())) });
|
|
143
|
+
const ThinkingValue = T.union(T.str(), T.nul());
|
|
144
|
+
const ThinkingLevelMap = T.obj({ off: opt(ThinkingValue), minimal: opt(ThinkingValue), low: opt(ThinkingValue), medium: opt(ThinkingValue), high: opt(ThinkingValue), xhigh: opt(ThinkingValue), max: opt(ThinkingValue) });
|
|
145
|
+
const KwargScalar = T.union(T.str(), T.num(), T.bool(), T.nul());
|
|
146
|
+
const KwargVariable = T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"))), omitWhenOff: opt(T.bool()) });
|
|
147
|
+
const Kwarg = T.union(KwargScalar, KwargVariable);
|
|
148
|
+
const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
|
|
149
|
+
const OpenAICompletionsCompat = T.obj({
|
|
150
|
+
supportsStore: opt(T.bool()),
|
|
151
|
+
supportsDeveloperRole: opt(T.bool()),
|
|
152
|
+
supportsReasoningEffort: opt(T.bool()),
|
|
153
|
+
supportsUsageInStreaming: opt(T.bool()),
|
|
154
|
+
supportsFinishReason: opt(T.bool()),
|
|
155
|
+
maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
|
|
156
|
+
requiresToolResultName: opt(T.bool()),
|
|
157
|
+
requiresAssistantAfterToolResult: opt(T.bool()),
|
|
158
|
+
requiresThinkingAsText: opt(T.bool()),
|
|
159
|
+
requiresReasoningContentOnAssistantMessages: opt(T.bool()),
|
|
160
|
+
thinkingFormat: opt(T.union(...["openai", "openrouter", "together", "baseten", "deepseek", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
|
|
161
|
+
chatTemplateKwargs: opt(T.rec(Kwarg)),
|
|
162
|
+
chatTemplateArgs: opt(T.rec(Kwarg)),
|
|
163
|
+
cacheControlFormat: opt(T.lit("anthropic")),
|
|
164
|
+
openRouterRouting: opt(OpenRouterRouting),
|
|
165
|
+
vercelGatewayRouting: opt(VercelGatewayRouting),
|
|
166
|
+
supportsOpenAIGrammarTools: opt(T.bool()),
|
|
167
|
+
supportsStrictMode: opt(T.bool()),
|
|
168
|
+
sendSessionAffinityHeaders: opt(T.bool()),
|
|
169
|
+
sessionAffinityFormat: opt(affinity),
|
|
170
|
+
supportsLongCacheRetention: opt(T.bool()),
|
|
171
|
+
vllmPriority: opt(T.num()),
|
|
172
|
+
});
|
|
173
|
+
const OpenAIResponsesCompat = T.obj({
|
|
174
|
+
supportsDeveloperRole: opt(T.bool()),
|
|
175
|
+
sessionAffinityFormat: opt(affinity),
|
|
176
|
+
supportsLongCacheRetention: opt(T.bool()),
|
|
177
|
+
supportsStrictMode: opt(T.bool()),
|
|
178
|
+
supportsOpenAIGrammarTools: opt(T.bool()),
|
|
179
|
+
supportsMaxOutputTokens: opt(T.bool()),
|
|
180
|
+
});
|
|
181
|
+
const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
|
|
182
|
+
const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
|
|
183
|
+
const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
|
|
184
|
+
const ModelPromptCache = T.obj({ short: opt(T.num({ exclusiveMinimum: 0 })), long: opt(T.num({ exclusiveMinimum: 0 })) });
|
|
185
|
+
const posInt = T.int({ minimum: 1 });
|
|
186
|
+
const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBytes: opt(posInt), jpegQuality: opt(T.int({ minimum: 1, maximum: 100 })) });
|
|
187
|
+
const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
|
|
188
|
+
const AnthropicMessagesCompat = T.obj({
|
|
189
|
+
supportsEagerToolInputStreaming: opt(T.bool()),
|
|
190
|
+
supportsLongCacheRetention: opt(T.bool()),
|
|
191
|
+
sendSessionAffinityHeaders: opt(T.bool()),
|
|
192
|
+
supportsCacheControlOnTools: opt(T.bool()),
|
|
193
|
+
supportsTemperature: opt(T.bool()),
|
|
194
|
+
forceAdaptiveThinking: opt(T.bool()),
|
|
195
|
+
allowEmptySignature: opt(T.bool()),
|
|
196
|
+
supportsStrictTools: opt(T.bool()),
|
|
197
|
+
supportsMidConvoEffort: opt(T.bool()),
|
|
198
|
+
allowedFallbackModels: opt(T.arr(T.obj({ provider: req(T.str({ minLength: 1 })), model: req(T.str({ minLength: 1 })), cost: req(ModelCost) }), { maxItems: 3 })),
|
|
199
|
+
});
|
|
200
|
+
const ProviderCompat = T.union(OpenAICompletionsCompat, OpenAIResponsesCompat, AnthropicMessagesCompat);
|
|
201
|
+
const nonEmpty = T.str({ minLength: 1 });
|
|
202
|
+
const inputKinds = T.arr(T.union(T.lit("text"), T.lit("image")));
|
|
203
|
+
const headers = T.rec(T.str());
|
|
204
|
+
const ModelDefinition = T.obj({
|
|
205
|
+
id: req(nonEmpty),
|
|
206
|
+
name: opt(nonEmpty),
|
|
207
|
+
api: opt(nonEmpty),
|
|
208
|
+
baseUrl: opt(nonEmpty),
|
|
209
|
+
reasoning: opt(T.bool()),
|
|
210
|
+
thinkingLevelMap: opt(ThinkingLevelMap),
|
|
211
|
+
input: opt(inputKinds),
|
|
212
|
+
inputLimits: opt(ModelInputLimits),
|
|
213
|
+
cost: opt(ModelCost),
|
|
214
|
+
promptCache: opt(ModelPromptCache),
|
|
215
|
+
contextWindow: opt(T.num()),
|
|
216
|
+
maxTokens: opt(T.num()),
|
|
217
|
+
samplingParams: opt(T.rec(T.unknown())),
|
|
218
|
+
headers: opt(headers),
|
|
219
|
+
compat: opt(ProviderCompat),
|
|
220
|
+
});
|
|
221
|
+
const ModelOverride = T.obj({
|
|
222
|
+
name: opt(nonEmpty),
|
|
223
|
+
reasoning: opt(T.bool()),
|
|
224
|
+
thinkingLevelMap: opt(ThinkingLevelMap),
|
|
225
|
+
input: opt(inputKinds),
|
|
226
|
+
inputLimits: opt(ModelInputLimits),
|
|
227
|
+
cost: opt(T.obj({ input: opt(T.num()), output: opt(T.num()), cacheRead: opt(T.num()), cacheWrite: opt(T.num()), tiers: opt(T.arr(ModelCostTier)) })),
|
|
228
|
+
promptCache: opt(ModelPromptCache),
|
|
229
|
+
contextWindow: opt(T.num()),
|
|
230
|
+
maxTokens: opt(T.num()),
|
|
231
|
+
samplingParams: opt(T.rec(T.unknown())),
|
|
232
|
+
headers: opt(headers),
|
|
233
|
+
compat: opt(ProviderCompat),
|
|
234
|
+
});
|
|
235
|
+
const ProviderConfig = T.obj({
|
|
236
|
+
name: opt(nonEmpty),
|
|
237
|
+
baseUrl: opt(nonEmpty),
|
|
238
|
+
apiKey: opt(nonEmpty),
|
|
239
|
+
api: opt(nonEmpty),
|
|
240
|
+
oauth: opt(T.lit("radius")),
|
|
241
|
+
headers: opt(headers),
|
|
242
|
+
compat: opt(ProviderCompat),
|
|
243
|
+
authHeader: opt(T.bool()),
|
|
244
|
+
models: opt(T.arr(ModelDefinition)),
|
|
245
|
+
modelOverrides: opt(T.rec(ModelOverride)),
|
|
246
|
+
});
|
|
247
|
+
const ModelsConfig = T.obj({ providers: req(T.rec(ProviderConfig)) });
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Parse overlay `models.json` TEXT as pi does. `{ value }` when pi would load it, else `{ error }` with a fixed
|
|
251
|
+
* phrase (never the file's text: models.json may hold keys, and a JSON.parse message quotes around the fault).
|
|
252
|
+
*/
|
|
253
|
+
export function parseModelsJson(text) {
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(stripJsonComments(stripBom(String(text))));
|
|
257
|
+
} catch {
|
|
258
|
+
return { error: "is not valid JSON" };
|
|
259
|
+
}
|
|
260
|
+
if (!ModelsConfig(parsed)) return { error: "does not match pi's models.json schema, so pi would load none of it" };
|
|
261
|
+
return { value: parsed };
|
|
262
|
+
}
|
package/src/money.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The money type (issue #501): every dollar amount pi-dispatch stores, compares or sends is an INTEGER number
|
|
3
|
+
* of micro-dollars (1 USD = 1,000,000). Integers add exactly, in JavaScript and in Valkey's INCRBY alike; a
|
|
4
|
+
* float total drifts, and a cap compared against a drifted total is a cap that admits one call too many.
|
|
5
|
+
*
|
|
6
|
+
* PURE and import-free, on `model-ref.mjs`'s rule: `triggers.mjs` (which the receiver loads and the admin
|
|
7
|
+
* bundle inlines) and `runtime-settings.mjs` (which the admin imports) both validate with it, and neither may
|
|
8
|
+
* drag config.mjs's fs and os in with it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Micro-dollars in one dollar. */
|
|
12
|
+
export const MICROS_PER_USD = 1_000_000;
|
|
13
|
+
|
|
14
|
+
/** The largest amount a setting may name: 1,000,000 USD. Far above any real cap, and it keeps every sum safe. */
|
|
15
|
+
export const MAX_USD_MICROS = 1_000_000 * MICROS_PER_USD;
|
|
16
|
+
|
|
17
|
+
// At most 7 integer digits (the range check below does the real bounding), at most 6 decimals, no sign, no
|
|
18
|
+
// exponent, no leading zeros, no whitespace. The integer part is limited so the arithmetic below stays far
|
|
19
|
+
// inside Number.MAX_SAFE_INTEGER whatever it is handed.
|
|
20
|
+
const USD_PATTERN = /^(0|[1-9]\d{0,6})(\.\d{1,6})?$/;
|
|
21
|
+
|
|
22
|
+
/** The dollar keys a deployment can set, in the order the settings list them. */
|
|
23
|
+
export const DOLLAR_SETTING_KEYS = Object.freeze(["maxCostUsd", "dailyCostUsd", "weeklyCostUsd", "monthlyCostUsd"]);
|
|
24
|
+
/** Each dollar key's environment variable: one table for config.mjs, doctor and the console. */
|
|
25
|
+
export const DOLLAR_ENV_NAMES = Object.freeze({ maxCostUsd: "PI_MAX_COST_USD", dailyCostUsd: "PI_DAILY_COST_USD", weeklyCostUsd: "PI_WEEKLY_COST_USD", monthlyCostUsd: "PI_MONTHLY_COST_USD" });
|
|
26
|
+
/** The three dollar WINDOWS, each of which needs a per-job cap to reserve (`checkDollarInvariant`). */
|
|
27
|
+
export const DOLLAR_WINDOW_KEYS = Object.freeze(["dailyCostUsd", "weeklyCostUsd", "monthlyCostUsd"]);
|
|
28
|
+
|
|
29
|
+
// config.mjs's `configError` shape, built here so this module stays import-free (model-ref.mjs does the same).
|
|
30
|
+
function configError(message) {
|
|
31
|
+
return Object.assign(new Error(message), { piDispatchConfig: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a dollar amount into integer micro-dollars, or throw a tagged config error naming `key`.
|
|
36
|
+
*
|
|
37
|
+
A STRING is read exactly as written, and that is the form to recommend: the pattern below is applied to
|
|
38
|
+
* the operator's own characters, so `"1e3"`, `"0.1234567"` and `" 2"` are refused.
|
|
39
|
+
*
|
|
40
|
+
* A NUMBER is read by its VALUE, as `String(n)`, the decimal JavaScript prints for it. JSON has already
|
|
41
|
+
* turned the operator's characters into a float before this runs, so what they typed is gone: `1e2` in a
|
|
42
|
+
* JSON file is the number 100 and passes as $100, `0.5e1` passes as $5, and `1.00000000000000001` has lost
|
|
43
|
+
* its last digits to the float and passes as $1. Only a value whose PRINTED form breaks the pattern is
|
|
44
|
+
* refused: `1e-7` prints "1e-7", `0.1 + 0.2` prints "0.30000000000000004", `1e21` prints "1e+21". So "never
|
|
45
|
+
* rounded, no exponents" holds for strings; for a number it holds for the value JavaScript prints.
|
|
46
|
+
*
|
|
47
|
+
* The conversion is INTEGER-ONLY: the whole part times 1,000,000 plus the decimals padded to six digits.
|
|
48
|
+
* `Math.round(n * 1e6)` WITHOUT the pattern was the obvious alternative and is wrong, because it would quietly
|
|
49
|
+
* accept a value with seven decimals and pick a cap the operator never wrote. (With the pattern in front, it
|
|
50
|
+
* would compute the same integer; the integer form keeps the rule visible.)
|
|
51
|
+
*
|
|
52
|
+
* Above 0 and at most 1,000,000 USD. Zero is refused on purpose: a setting of 0 would read as "no cap" to
|
|
53
|
+
* anyone skimming a file, while the runner reads it as "no priced call at all". The error names the key, never
|
|
54
|
+
* the value: a value an operator typed into the wrong field could be anything.
|
|
55
|
+
*/
|
|
56
|
+
export function parseUsdMicros(value, key) {
|
|
57
|
+
const text = typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : null;
|
|
58
|
+
const match = text === null ? null : USD_PATTERN.exec(text);
|
|
59
|
+
if (match) {
|
|
60
|
+
const whole = Number(match[1]);
|
|
61
|
+
const fraction = Number((match[2] ?? ".").slice(1).padEnd(6, "0"));
|
|
62
|
+
const micros = whole * MICROS_PER_USD + fraction;
|
|
63
|
+
if (micros > 0 && micros <= MAX_USD_MICROS) return micros;
|
|
64
|
+
}
|
|
65
|
+
throw configError(`${key} must be a dollar amount above 0 and at most 1000000, with at most 6 decimals, written as a plain decimal (a string or a number)`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The same parse, for a value that may be absent: `null` and `undefined` are `null` (no cap), anything else is
|
|
70
|
+
* `parseUsdMicros`. Absence is how every dollar setting is switched off; 0 is not (see above).
|
|
71
|
+
*/
|
|
72
|
+
export function optionalUsdMicros(value, key) {
|
|
73
|
+
return value === null || value === undefined ? null : parseUsdMicros(value, key);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Integer micro-dollars as a plain dollar decimal, at least two decimals and no trailing zeros past them:
|
|
78
|
+
* 2500000 is "2.50", 1 is "0.000001". For messages and displays; never parsed back by anything that enforces.
|
|
79
|
+
*/
|
|
80
|
+
export function formatMicros(micros) {
|
|
81
|
+
if (!Number.isSafeInteger(micros) || micros < 0) throw new TypeError("formatMicros: want a non-negative safe integer");
|
|
82
|
+
const whole = Math.floor(micros / MICROS_PER_USD);
|
|
83
|
+
const fraction = String(micros % MICROS_PER_USD).padStart(6, "0").replace(/0+$/, "").padEnd(2, "0");
|
|
84
|
+
return `${whole}.${fraction}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The cross-key rule (issue #501): a dollar window without `maxCostUsd` is invalid, because a window reserves
|
|
89
|
+
* each job's per-job cap before the container starts, and without a cap there is no amount to reserve.
|
|
90
|
+
*
|
|
91
|
+
* It runs on MERGED values, never per key: an env `PI_MAX_COST_USD` with an overlay `dailyCostUsd` is valid,
|
|
92
|
+
* and a per-key check of the overlay alone would refuse it. `settings` holds the four keys as they were set
|
|
93
|
+
* (any value but `null`/`undefined` counts as set). Returns `null`, or `{ invalid }` naming the first window
|
|
94
|
+
* that lacks a cap.
|
|
95
|
+
*/
|
|
96
|
+
export function checkDollarInvariant(settings) {
|
|
97
|
+
const set = (key) => settings?.[key] !== null && settings?.[key] !== undefined;
|
|
98
|
+
if (set("maxCostUsd")) return null;
|
|
99
|
+
const window = DOLLAR_WINDOW_KEYS.find(set);
|
|
100
|
+
return window === undefined ? null : { invalid: `${window} needs maxCostUsd: a dollar window reserves each job's per-job cost cap before it starts, so it cannot be set without one` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The per-job cost cap a job runs under, in micro-dollars, or `null` for none (issue #501): the SMALLER of the
|
|
105
|
+
* trigger's `run.maxCostUsd` and the deployment's `maxCostUsd`, each counted only when set.
|
|
106
|
+
*
|
|
107
|
+
* A trigger can only NARROW. Where both are set the smaller wins, so a trigger can never lift a job above the
|
|
108
|
+
* deployment's cap (and a value above the env cap refuses the worker's load of the file too, `loadSchedules`). Where only the trigger sets one, it
|
|
109
|
+
* applies: a deployment that set no cap asked for none, and a trigger that asks for one is still a narrowing.
|
|
110
|
+
*
|
|
111
|
+
* The deployment value is validated before it gets here (config.mjs at boot, `validateOverlay` per job). The
|
|
112
|
+
* trigger value is validated by the loader in both services, so a malformed one reaches this only from a
|
|
113
|
+
* hand-built queue entry. That one does not throw (this runs at pickup, where a throw is a retry) and does not
|
|
114
|
+
* fall back to the deployment cap either, which would quietly drop a cap that was asked for: it reads as 0,
|
|
115
|
+
* the cap that refuses every priced call, and the run stops with `cost-cap`. `onMalformed(key)` is called
|
|
116
|
+
* when that happens, so the worker logs it rather than leaving a $0 cap unexplained.
|
|
117
|
+
*/
|
|
118
|
+
export function effectiveCostCapMicros(triggerValue, deploymentValue, onMalformed = () => {}) {
|
|
119
|
+
let trigger;
|
|
120
|
+
try {
|
|
121
|
+
trigger = optionalUsdMicros(triggerValue, "run.maxCostUsd");
|
|
122
|
+
} catch {
|
|
123
|
+
// Fail closed, and say so: `onMalformed` is told the KEY (never the value), so the caller can log it.
|
|
124
|
+
trigger = 0;
|
|
125
|
+
onMalformed("maxCostUsd");
|
|
126
|
+
}
|
|
127
|
+
const deployment = optionalUsdMicros(deploymentValue, "maxCostUsd");
|
|
128
|
+
if (trigger === null) return deployment;
|
|
129
|
+
if (deployment === null) return trigger;
|
|
130
|
+
return Math.min(trigger, deployment);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The first parsed trigger whose `run.maxCostUsd` is above the deployment's `maxCostUsd`, as its index in
|
|
135
|
+
* `triggers`, or -1 (issue #501). A trigger may only narrow the per-job cap, so a value above it can only be a
|
|
136
|
+
* mistake: the job would run under the deployment's cap anyway, while the file reads as allowing more. Pure,
|
|
137
|
+
* so `triggers.mjs` stays env-free and the worker's trigger load and doctor ask the same question. An unset
|
|
138
|
+
* deployment cap has nothing to be above.
|
|
139
|
+
*/
|
|
140
|
+
export function triggerCapAboveDeployment(triggers, deploymentValue) {
|
|
141
|
+
const deployment = optionalUsdMicros(deploymentValue, "maxCostUsd");
|
|
142
|
+
if (deployment === null) return -1;
|
|
143
|
+
return triggers.findIndex((t) => t?.run?.maxCostUsd !== undefined && t.run.maxCostUsd !== null && parseUsdMicros(t.run.maxCostUsd, "run.maxCostUsd") > deployment);
|
|
144
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `log` option every Octokit this project builds is given (issue #530), so no GitHub client writes a plain line
|
|
3
|
+
* into a process whose log stream is one JSON object per line.
|
|
4
|
+
*
|
|
5
|
+
* Octokit's own default logger is `console.warn` and `console.error`, and `@octokit/rest` carries the request-log
|
|
6
|
+
* plugin, which calls `log.error` with `GET /user - 401 with id ... in 12ms` for every failed request. A worker booted
|
|
7
|
+
* with `GITHUB_AUTH_SOURCE=pat` and a refused token printed exactly that beside its `github_auth_unavailable` line, and
|
|
8
|
+
* a pipeline that parses each line as JSON breaks on it.
|
|
9
|
+
*
|
|
10
|
+
* - `debug`, `info` and `error` are dropped. The request-log plugin is the only caller of `info` and `error`, and its
|
|
11
|
+
* `error` is always followed by the rejection itself, which the caller already reports in its own words (the boot
|
|
12
|
+
* line's `reason`, a job's refusal). Logging it twice would also put a request path into the stream.
|
|
13
|
+
* - `warn` is KEPT, as one JSON event: it is how `@octokit/request` says an endpoint is deprecated and how
|
|
14
|
+
* `@octokit/auth-app` says it retried past clock skew, and neither has another surface. Dropping it would make an
|
|
15
|
+
* endpoint that GitHub is about to remove fail with no warning at all.
|
|
16
|
+
*
|
|
17
|
+
* `log` is the caller's `(event, fields)` logger. Without one the warning still goes out as JSON, on stderr, where
|
|
18
|
+
* Octokit's own `console.warn` would have put it.
|
|
19
|
+
*
|
|
20
|
+
* An Octokit takes it TWICE, which is why the sites spread `octokitLogOptions` rather than pass `log` alone: the
|
|
21
|
+
* deprecation warning is written by `@octokit/request`'s fetch wrapper, which reads `request.log` and falls back to
|
|
22
|
+
* `console`, never the client's own `log` (measured on @octokit/request 10.0.11: with `log` alone the warning still
|
|
23
|
+
* reached stderr as a plain line).
|
|
24
|
+
*/
|
|
25
|
+
export function octokitLog(log = stderrJson) {
|
|
26
|
+
return {
|
|
27
|
+
debug() {},
|
|
28
|
+
info() {},
|
|
29
|
+
warn: (message) => log("github_client_warning", { message: String(message) }),
|
|
30
|
+
error() {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The two places an Octokit reads its logger from, as constructor options to spread. */
|
|
35
|
+
export function octokitLogOptions(log) {
|
|
36
|
+
const logger = octokitLog(log);
|
|
37
|
+
return { log: logger, request: { log: logger } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a failure log line may say about a GitHub answer: `{ status, requestId }` from the first Octokit `RequestError`
|
|
42
|
+
* on the error or its `cause` chain, else `{}` (issue #530's review). Dropping Octokit's own error line above also
|
|
43
|
+
* dropped `x-github-request-id`, which is what GitHub support asks for; this puts it back on OUR event.
|
|
44
|
+
*
|
|
45
|
+
* Only those two fields, never the headers object, the request or the response: a RequestError carries the request
|
|
46
|
+
* options and the response body, and only the `authorization` header is redacted there (measured on
|
|
47
|
+
* @octokit/request-error with this project's pins), not a token in a URL or body. A RequestError is recognised by its name, which
|
|
48
|
+
* `@octokit/request-error` sets to "HttpError", and an integer status; a gitlab, forgejo or azure error has neither, so
|
|
49
|
+
* it adds nothing. The id is GitHub's text, kept only in its own shape (hex, `:` and `.`), so the field can never carry
|
|
50
|
+
* anything else into the log.
|
|
51
|
+
*/
|
|
52
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9:.]{1,100}$/;
|
|
53
|
+
export function githubFailureFields(error) {
|
|
54
|
+
for (let e = error, depth = 0; e && depth < 5; e = e.cause, depth += 1) {
|
|
55
|
+
if (e.name === "HttpError" && Number.isInteger(e.status)) {
|
|
56
|
+
const id = e.response?.headers?.["x-github-request-id"];
|
|
57
|
+
return { status: e.status, ...(typeof id === "string" && REQUEST_ID_RE.test(id) ? { requestId: id } : {}) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stderrJson(event, fields) {
|
|
64
|
+
process.stderr.write(`${JSON.stringify({ event, ...fields })}\n`);
|
|
65
|
+
}
|