@edgehero/pi-dispatch 2.0.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 +44 -7
- package/README.md +14 -6
- package/deploy/com.pi-dispatch.worker.plist +1 -1
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/deploy/worker-env-wrapper.cmd +1 -1
- package/deploy/worker-env-wrapper.sh +3 -3
- package/package.json +9 -2
- 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 +2316 -183
- 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 +16 -3
- package/src/env-allowlist.mjs +142 -18
- 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/host-pi.mjs +19 -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/packages.mjs +2 -2
- 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/pricing.mjs +9 -5
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +603 -173
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-key.mjs +32 -7
- package/src/provider-steering.mjs +214 -59
- 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/secrets.mjs +2 -1
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/subscriptions.mjs +7 -3
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +140 -9
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project id rule (issue #499, INT-PROJECTS-FILE-CONTRACT), in a module with NO imports. `run-history.mjs` checks a
|
|
3
|
+
* record's `project` against it, and the admin loads run-history inside pi, so the record module must not drag the
|
|
4
|
+
* projects parser's graph (config.mjs and its fs, os and child_process) in with it: the rule `model-ref.mjs` keeps for
|
|
5
|
+
* the same reader. `projects.mjs` re-exports both names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A project id: lowercase, 1 to 32 characters, free of `:`, `#` and `/`, so it can enter a run record and a Valkey key
|
|
10
|
+
* without escaping.
|
|
11
|
+
*/
|
|
12
|
+
export const PROJECT_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
13
|
+
|
|
14
|
+
/** Is this a well-formed project id? The record path's charset check. */
|
|
15
|
+
export function isProjectId(value) {
|
|
16
|
+
return typeof value === "string" && PROJECT_ID_RE.test(value);
|
|
17
|
+
}
|
package/src/projects.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects (issue #499, INT-PROJECTS-FILE-CONTRACT): a named group of repos and folders. One `projects.json` of
|
|
3
|
+
* `{ version: 1, projects: [{ id, name?, members }] }`. A job whose scope is a member belongs to that project, and the
|
|
4
|
+
* project's id is written into the job's run record (`project`), so spend can later be read and capped per project.
|
|
5
|
+
*
|
|
6
|
+
* This module is pure and fs-injectable, on the scoped-limits.mjs pattern: `parseProjects` validates the file TEXT
|
|
7
|
+
* fail-loud, `loadProjects` layers the one fs read on top, and `projectOf` is what the pickup gate and the record path
|
|
8
|
+
* consume. The worker holds the parsed list in a watched ref with a last-good copy (start.mjs).
|
|
9
|
+
*
|
|
10
|
+
* `version` is REQUIRED and a newer one is refused: which project a scope belongs to decides which cap applies to it
|
|
11
|
+
* (issue #499 part B), so this is a money file, and a field an old build silently drops could widen a cap.
|
|
12
|
+
*
|
|
13
|
+
* `name` is display text for the panel. It never enters a record or a log line: the record carries the `id`, which is
|
|
14
|
+
* charset-checked, and so stays free of personal data by construction. For the same reason no error message here
|
|
15
|
+
* quotes a name, and an invalid-JSON error does not quote the parser's message, which carries file text.
|
|
16
|
+
*
|
|
17
|
+
* Custom: projects validated inline per scoped-limits.mjs precedent; zod not in deps
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
21
|
+
import { isAbsolute, resolve } from "node:path";
|
|
22
|
+
import { configError } from "./config.mjs";
|
|
23
|
+
import { fingerprint } from "./fingerprint.mjs";
|
|
24
|
+
import { hash16 } from "./fleet-lease.mjs";
|
|
25
|
+
import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
|
|
26
|
+
import { parseScopeString, qualifiedScopeOf } from "./pause-windows.mjs";
|
|
27
|
+
import { canonicalScope } from "./scoped-limits.mjs";
|
|
28
|
+
|
|
29
|
+
/** The highest schema version this build reads. A file declaring a higher one is refused loudly. */
|
|
30
|
+
export const PROJECTS_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
// The id rule lives in an import-free module, so the run record can check an id without this module's graph.
|
|
33
|
+
export { PROJECT_ID_RE, isProjectId };
|
|
34
|
+
|
|
35
|
+
/** The longest `name` accepted, in UTF-16 code units. A display label, not a document. */
|
|
36
|
+
const NAME_MAX = 120;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse, validate and normalize the projects file TEXT. Returns the normalized list: each project rebuilt as an
|
|
40
|
+
* explicit `{ id, name, members }` literal (`name` null when absent, members in their stored spelling, unknown fields
|
|
41
|
+
* dropped by the operator-file policy). Throws `configError` on anything malformed. `path` is for messages only.
|
|
42
|
+
*
|
|
43
|
+
* Members use the scope grammar of `parseScopeString` (issue #498):
|
|
44
|
+
* - a forge-qualified scope (`github:acme/web`), stored as `<kind>:<repo>`;
|
|
45
|
+
* - an absolute folder (`/srv/shop-tools`), stored resolved, the spelling `canonicalScope` gives a local job.
|
|
46
|
+
* A bare `owner/name` is refused: it names that repo on every forge, and this file has no legacy to keep. A relative
|
|
47
|
+
* folder, a drive path on a host where it is not absolute, `*` and globs are refused too: each would be a member no
|
|
48
|
+
* job's scope can ever equal.
|
|
49
|
+
*
|
|
50
|
+
* Refused across the file: a duplicate id, a scope claimed by two projects (both ids named, one project per scope),
|
|
51
|
+
* a scope listed twice in one project, and an empty `members`.
|
|
52
|
+
*/
|
|
53
|
+
export function parseProjects(text, path) {
|
|
54
|
+
try {
|
|
55
|
+
return parseProjectsText(text, path);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// Every refusal ESCAPED at the source (PR #569's review): a folder member may hold a C1 or bidi character, and this
|
|
58
|
+
// message reaches the worker's `projects_reload_invalid` log line, doctor and an admin tool's error. JSON quoting
|
|
59
|
+
// keeps C0 visible but leaves C1, bidi and zero-width characters raw.
|
|
60
|
+
if (error?.piDispatchConfig === true) error.message = escapeControls(error.message);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The characters `escapeControls` writes out, the SAME set the admin panel's `escapeInterpreted` escapes (PR #569's
|
|
67
|
+
* second review; `admin/test/projects.test.mjs` compares the two over every code point): the controls (C0, DEL, C1),
|
|
68
|
+
* every format character (bidi controls and isolates, zero-width and invisible ones, the tag block) but the two joiners
|
|
69
|
+
* that compose (U+200C, U+200D), the line and paragraph separators, every blank that is not U+0020 but draws one
|
|
70
|
+
* column (the no-break and fixed-width spaces, U+2800), the Hangul fillers, and the unassigned code points the
|
|
71
|
+
* terminal draws as nothing (U+2065, U+FFF0-U+FFF8, the special-purpose plane outside its variation selectors).
|
|
72
|
+
* U+3000 is kept, as the panel keeps it: it draws two columns, an ordinary full-width space.
|
|
73
|
+
*/
|
|
74
|
+
const ESCAPED = /(?![\u200c\u200d\u3000\u{e0100}-\u{e01ef}])[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}\u2800\u115f\u1160\u3164\uffa0\u2065\ufff0-\ufff8\u{e0000}-\u{e0fff}]/gu;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Text with the characters that change what a reader SEES written out as `\\u{XXXX}` (`ESCAPED`), U+0020 kept. For a
|
|
78
|
+
* message about operator text that reaches a log line or a terminal. The worker's twin of the panel's
|
|
79
|
+
* `escapeInterpreted`; it imports nothing.
|
|
80
|
+
*/
|
|
81
|
+
export function escapeControls(text) {
|
|
82
|
+
return String(text ?? "").replace(ESCAPED, (ch) => (ch === " " ? ch : `\\u{${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}}`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseProjectsText(text, path) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(text);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// Not the parser's own message: it quotes the file's text around the fault, and that text may be a `name`.
|
|
91
|
+
const at = /position (\d+)/.exec(String(error?.message))?.[1];
|
|
92
|
+
throw configError(`projects file is not valid JSON${at === undefined ? "" : ` (at character ${at})`}: ${path}`);
|
|
93
|
+
}
|
|
94
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
95
|
+
throw configError(`projects file must be an object with "version" and "projects": ${path}`);
|
|
96
|
+
}
|
|
97
|
+
const version = parsed.version;
|
|
98
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
99
|
+
throw configError(`projects file must have "version": ${PROJECTS_VERSION} (an integer >= 1): ${path}`);
|
|
100
|
+
}
|
|
101
|
+
if (version > PROJECTS_VERSION) {
|
|
102
|
+
throw configError(`projects file written by a newer pi-dispatch (version ${version}; this build understands ${PROJECTS_VERSION}): ${path}`);
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(parsed.projects)) throw configError(`projects file must have a "projects" array: ${path}`);
|
|
105
|
+
const projects = parsed.projects.map((entry, index) => normalizeProject(entry, index, path));
|
|
106
|
+
const ids = new Map();
|
|
107
|
+
const owners = new Map();
|
|
108
|
+
projects.forEach((project, index) => {
|
|
109
|
+
if (ids.has(project.id)) {
|
|
110
|
+
throw configError(`project at index ${index}: duplicate id "${project.id}" (first at index ${ids.get(project.id)}): ${path}`);
|
|
111
|
+
}
|
|
112
|
+
ids.set(project.id, index);
|
|
113
|
+
for (const member of project.members) {
|
|
114
|
+
const owner = owners.get(member);
|
|
115
|
+
// One project per scope: a job in two projects would have two project ledgers and an unclear refusal.
|
|
116
|
+
if (owner !== undefined) {
|
|
117
|
+
throw configError(`project at index ${index}: ${JSON.stringify(member)} is claimed by both "${owner}" and "${project.id}" (a scope belongs to one project): ${path}`);
|
|
118
|
+
}
|
|
119
|
+
owners.set(member, project.id);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return projects;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeProject(entry, index, path) {
|
|
126
|
+
const at = `project at index ${index}`;
|
|
127
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw configError(`${at}: must be an object: ${path}`);
|
|
128
|
+
if (!isProjectId(entry.id)) {
|
|
129
|
+
throw configError(`${at}: id must match ${PROJECT_ID_RE.source} (lowercase letters, digits and "-", 1 to 32 characters, starting with a letter or digit): ${path}`);
|
|
130
|
+
}
|
|
131
|
+
const id = entry.id;
|
|
132
|
+
let name = null;
|
|
133
|
+
if (entry.name !== undefined && entry.name !== null) {
|
|
134
|
+
// The name's own text is never echoed: it is free text, and this message can reach a log line.
|
|
135
|
+
if (typeof entry.name !== "string" || entry.name.trim() === "" || entry.name.length > NAME_MAX || /[\u0000-\u001f\u007f]/.test(entry.name)) {
|
|
136
|
+
throw configError(`${at} ("${id}"): name, when given, must be a string of 1 to ${NAME_MAX} characters with no control characters: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
name = entry.name.trim();
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(entry.members) || entry.members.length === 0) {
|
|
141
|
+
throw configError(`${at} ("${id}"): members must be a non-empty array of scopes (github:owner/name or an absolute folder): ${path}`);
|
|
142
|
+
}
|
|
143
|
+
const members = [];
|
|
144
|
+
entry.members.forEach((raw, m) => {
|
|
145
|
+
const member = normalizeMember(raw, `${at} ("${id}") member ${m}`, path);
|
|
146
|
+
if (members.includes(member)) throw configError(`${at} ("${id}") member ${m}: ${JSON.stringify(member)} is listed twice: ${path}`);
|
|
147
|
+
members.push(member);
|
|
148
|
+
});
|
|
149
|
+
return { id, name, members };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** One member, in its stored spelling, or the refusal naming where it sits. */
|
|
153
|
+
function normalizeMember(raw, at, path) {
|
|
154
|
+
if (typeof raw !== "string" || raw.trim() === "") throw configError(`${at}: must be a non-empty string: ${path}`);
|
|
155
|
+
const trimmed = raw.trim().normalize("NFC");
|
|
156
|
+
if (trimmed.includes("*")) throw configError(`${at}: members match exactly; a scope containing "*" is refused (no globs): ${path}`);
|
|
157
|
+
let form;
|
|
158
|
+
try {
|
|
159
|
+
form = parseScopeString(trimmed);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw configError(`${at}: ${error.message}: ${path}`);
|
|
162
|
+
}
|
|
163
|
+
if (form.type === "qualified") return `${form.kind}:${form.repo}`;
|
|
164
|
+
if (form.type === "bare") {
|
|
165
|
+
throw configError(`${at}: ${JSON.stringify(trimmed)} is a bare repo, which names that repo on every forge; write it with its forge, such as github:${trimmed}, or give an absolute folder: ${path}`);
|
|
166
|
+
}
|
|
167
|
+
// `local`: platform-native isAbsolute, so a drive path on a POSIX host is refused rather than kept as a member no
|
|
168
|
+
// job here can have (scoped limits keep such a row verbatim and inert; a new file need not).
|
|
169
|
+
if (!isAbsolute(trimmed)) throw configError(`${at}: ${JSON.stringify(trimmed)} is not an absolute path on this host: ${path}`);
|
|
170
|
+
return resolve(trimmed);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Load and validate the projects file named by `config.projectsFile`. Returns `[]` when it is unset (no projects, a
|
|
175
|
+
* valid deployment). An empty string is a value, so it reaches `existsSync` and is refused, as the scoped-limits key's
|
|
176
|
+
* is. `readFileSync`/`existsSync` are injectable for tests.
|
|
177
|
+
*/
|
|
178
|
+
export function loadProjects(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
179
|
+
const path = config.projectsFile;
|
|
180
|
+
if (path === null || path === undefined) return [];
|
|
181
|
+
if (!existsSync(path)) throw configError(`projects file does not exist: ${escapeControls(path)}`);
|
|
182
|
+
return parseProjects(readFileSync(path, "utf8"), path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The id of the project this job belongs to, or null. `job` is job data (`kind`, `repo`, `folder`). A forge job is
|
|
187
|
+
* matched by its forge-qualified scope (`github:acme/web`), never by its bare repo, so a GitHub job and a Forgejo job
|
|
188
|
+
* for `acme/web` can sit in different projects. A local job is matched by its resolved folder (`canonicalScope`), the
|
|
189
|
+
* spelling a member is stored in, so `/srv/shop-tools/` matches the member `/srv/shop-tools`.
|
|
190
|
+
*/
|
|
191
|
+
export function projectOf(job, projects) {
|
|
192
|
+
if (!Array.isArray(projects) || projects.length === 0) return null;
|
|
193
|
+
const scope = memberScopeOf(job);
|
|
194
|
+
if (typeof scope !== "string" || scope === "") return null;
|
|
195
|
+
for (const project of projects) {
|
|
196
|
+
if (Array.isArray(project?.members) && project.members.includes(scope)) return project.id;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The scope a job is matched to a member by (`projectOf`): its forge-qualified scope, or for a local job its resolved
|
|
203
|
+
* folder. When `projectOf` found a project, this IS the member it matched, in the stored spelling a priorities plan
|
|
204
|
+
* names by `scopeRef` (issue #504 part B), so the repo share a plan gave it needs no second matching rule.
|
|
205
|
+
*/
|
|
206
|
+
export function memberScopeOf(job) {
|
|
207
|
+
return job?.kind === "local" ? canonicalScope(job) : qualifiedScopeOf(job);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* What `fpProjects` hashes (issue #499 part C, INT-HOST-REGISTRY-CONTRACT), exported so a test can read it: one entry
|
|
212
|
+
* per project, sorted by id, as `{ id, members }` where `members` is the sorted 16-hex hash of each member. Never a
|
|
213
|
+
* `name`, which is free text, and never a member in clear: a folder member is a host path and a repo member a
|
|
214
|
+
* repository name, and the registry's content rule keeps both out of a Valkey value, even inside a digest's input.
|
|
215
|
+
* The id is charset-checked operator text, the same admissibility a run record gives it.
|
|
216
|
+
*
|
|
217
|
+
* Membership is what the comparison is about: two hosts that put one scope in two projects, or a scope in a project on
|
|
218
|
+
* one host and in none on the other, record different projects and count the scope against different project rows.
|
|
219
|
+
* A `name` decides nothing, so renaming the display text on one host is not a disagreement.
|
|
220
|
+
*/
|
|
221
|
+
export function projectsFingerprintInput(projects) {
|
|
222
|
+
return (Array.isArray(projects) ? projects : [])
|
|
223
|
+
.filter((p) => isProjectId(p?.id))
|
|
224
|
+
.map((p) => ({ id: p.id, members: (Array.isArray(p.members) ? p.members : []).map((m) => hash16(String(m))).sort() }))
|
|
225
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The 16-hex fingerprint `fpProjects` of a host's live projects (`fingerprint.mjs`). It never abstains: a host with no
|
|
230
|
+
* projects file publishes the fingerprint of no projects, because a host that puts a repo in no project while a peer
|
|
231
|
+
* puts it in one is the disagreement worth seeing.
|
|
232
|
+
*/
|
|
233
|
+
export function projectsFingerprint(projects) {
|
|
234
|
+
return fingerprint(projectsFingerprintInput(projects));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The fingerprint of no projects: how a reader tells whether projects are in use anywhere on the fleet. */
|
|
238
|
+
export const EMPTY_PROJECTS_FINGERPRINT = projectsFingerprint([]);
|
package/src/provider-key.mjs
CHANGED
|
@@ -27,15 +27,40 @@
|
|
|
27
27
|
// worker/test/provider-key.test.mjs -- never against a second copy of a table.
|
|
28
28
|
export const OAUTH_KEY_RE = /_OAUTH_TOKEN$/;
|
|
29
29
|
|
|
30
|
+
// The second fact of the same kind, added with the pi 0.99.1 bump (issue #509): a variable pi reads for a
|
|
31
|
+
// provider that is NOT an API key, because pi sends it as a different header. At 0.99.1 pi lists
|
|
32
|
+
// ANTHROPIC_AUTH_TOKEN FIRST for `anthropic` (pi-ai/dist/env-api-keys.js getApiKeyEnvVars), and its
|
|
33
|
+
// resolver (pi-ai/dist/providers/anthropic.js) sends it as `Authorization: Bearer <value>` AHEAD of both
|
|
34
|
+
// ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY. Without this rule `apiKeyVariable` picked it: an auth.json
|
|
35
|
+
// API key would have been written into the container under the bearer name, so pi would send it as
|
|
36
|
+
// `Authorization: Bearer` rather than as the `x-api-key` an API key travels in, and doctor would have told
|
|
37
|
+
// an operator to set the bearer variable.
|
|
38
|
+
// pi's own `getEnvApiKey` skips it for the same reason, which is the evidence that the distinction is real
|
|
39
|
+
// rather than ours. A suffix rule, for OAUTH_KEY_RE's reason: a set naming one variable would silently
|
|
40
|
+
// bless the next provider's bearer variable. Pinned against pi in worker/test/provider-key.test.mjs.
|
|
41
|
+
export const BEARER_KEY_RE = /_AUTH_TOKEN$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why a variable pi reads is not an API key, or null when it is one. The two answers need different
|
|
45
|
+
* advice, so the caller is told which: an OAuth token is a subscription login that expires, a bearer token
|
|
46
|
+
* is a credential pi sends in another header and reads BEFORE the API key.
|
|
47
|
+
*/
|
|
48
|
+
export function nonApiKeyKind(name) {
|
|
49
|
+
if (OAUTH_KEY_RE.test(name)) return "oauth";
|
|
50
|
+
if (BEARER_KEY_RE.test(name)) return "bearer";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
/**
|
|
31
55
|
* The api-key variable to write and to name, given pi's candidate list for a provider in pi's own
|
|
32
|
-
* precedence order. Never the OAuth token, whatever that precedence says: pi
|
|
33
|
-
* ANTHROPIC_OAUTH_TOKEN
|
|
34
|
-
* and an api-key credential written under
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
56
|
+
* precedence order. Never the OAuth token and never the bearer token, whatever that precedence says: pi
|
|
57
|
+
* returns ANTHROPIC_AUTH_TOKEN then ANTHROPIC_OAUTH_TOKEN before ANTHROPIC_API_KEY, "set your subscription
|
|
58
|
+
* login" is wrong advice for an unattended service, and an api-key credential written under either name
|
|
59
|
+
* would be a value whose variable lies about what it is (under the bearer name pi would also send it in
|
|
60
|
+
* the wrong header). Falls back to the first candidate only for a provider with no API-key variable at
|
|
61
|
+
* all, which is no provider pi has today; `null` for a provider pi reads no key variable for, which is the
|
|
62
|
+
* caller's cue to refuse rather than to guess.
|
|
38
63
|
*/
|
|
39
64
|
export function apiKeyVariable(candidates) {
|
|
40
|
-
return candidates.find((name) =>
|
|
65
|
+
return candidates.find((name) => nonApiKeyKind(name) === null) ?? candidates[0] ?? null;
|
|
41
66
|
}
|