@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,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared model endpoints (issue #503, INT-MODEL-ENDPOINTS-FILE-CONTRACT): the local or LAN model servers
|
|
3
|
+
* (Ollama, vLLM, llama.cpp, LM Studio) a job may reach through the egress proxy. One `model-endpoints.json` of
|
|
4
|
+
* `{ "version": 1, "endpoints": [ { id, host, port, slots, keyless? } ] }` in the deployment folder.
|
|
5
|
+
*
|
|
6
|
+
* This module is pure and fs-injectable, in scoped-limits.mjs' style: `parseModelEndpoints` validates the file TEXT
|
|
7
|
+
* and refuses, never repairs; `loadModelEndpoints` layers the one fs read on top; `endpointsForModel` derives which
|
|
8
|
+
* endpoints a model uses from the overlay `models.json`; `renderEndpointsInclude` writes the squid include the proxy
|
|
9
|
+
* reads. The module enforces nothing itself: the proxy include enforces the route, and the pickup gate in index.mjs
|
|
10
|
+
* enforces `slots` through a lease per endpoint (`DES-FLEET-LEASES-FOR-SHARED-BOUNDS`), and the credential gate
|
|
11
|
+
* passes a custom provider served by keyless endpoints alone (`keylessVerdict`). All of them bind to this one
|
|
12
|
+
* implementation.
|
|
13
|
+
*
|
|
14
|
+
* Which models use an endpoint is DERIVED, never listed twice: a model uses endpoint E when its effective `baseUrl`
|
|
15
|
+
* (the model's own, else its provider's) has E's host and port. A second list of model names here could disagree
|
|
16
|
+
* with models.json, and the disagreement would decide egress.
|
|
17
|
+
*
|
|
18
|
+
* Stricter than scoped-limits.json on unknown keys: they are REFUSED, not dropped. This file decides proxy rules,
|
|
19
|
+
* so a key an old worker drops is a rule the operator believes in and does not have.
|
|
20
|
+
*
|
|
21
|
+
* Not settable by a model-callable tool or by the settings overlay: the proxy rules derive from it, so a write
|
|
22
|
+
* would widen egress. The operator edits the file by hand.
|
|
23
|
+
*
|
|
24
|
+
* Custom: model endpoints validated inline per scoped-limits.mjs precedent; zod not in deps
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync as fsExistsSync, lstatSync as fsLstatSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
28
|
+
import { isIPv4 } from "node:net";
|
|
29
|
+
import { join, resolve } from "node:path";
|
|
30
|
+
import { PROXY_LOCAL_ADDRESSES, isProxyLocalHost } from "./backends.mjs";
|
|
31
|
+
import { configError } from "./config.mjs";
|
|
32
|
+
import { KEYLESS_ENV_NAME } from "./reserved-env.mjs";
|
|
33
|
+
import { parseModelsJson } from "./models-json.mjs";
|
|
34
|
+
|
|
35
|
+
/** The schema version this build reads. A file declaring a higher one is refused loudly. */
|
|
36
|
+
export const MODEL_ENDPOINTS_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** The file `pi-dispatch init` scaffolds, and the one the worker reads when PI_MODEL_ENDPOINTS_FILE is unset. */
|
|
39
|
+
export const MODEL_ENDPOINTS_FILE_NAME = "model-endpoints.json";
|
|
40
|
+
|
|
41
|
+
/** The proxy include rendered from it, beside it in the deployment folder. */
|
|
42
|
+
export const MODEL_ENDPOINTS_INCLUDE_NAME = "model-endpoints.conf";
|
|
43
|
+
|
|
44
|
+
/** The egress proxy's own listening port. An endpoint on it would be a tunnel back into the proxy. */
|
|
45
|
+
export const PROXY_PORT = 3128;
|
|
46
|
+
|
|
47
|
+
/** The largest `slots` value: a bound this large is already no bound for one server. */
|
|
48
|
+
export const MAX_SLOTS = 64;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An endpoint id. EXPORTED for doctor's dead-pid sweep, which matches a probe container's name by this same class:
|
|
52
|
+
* one rule, so the sweep cannot drift from what the parser accepts.
|
|
53
|
+
*/
|
|
54
|
+
export const MODEL_ENDPOINT_ID_RE = /^[a-z0-9-]{1,32}$/;
|
|
55
|
+
const ID_RE = MODEL_ENDPOINT_ID_RE;
|
|
56
|
+
const ENDPOINT_KEYS = new Set(["id", "host", "port", "slots", "keyless"]);
|
|
57
|
+
const FILE_KEYS = new Set(["version", "endpoints"]);
|
|
58
|
+
// A DNS label as the proxy and a URL both read it, lowercased. `_` is allowed because a container or compose name
|
|
59
|
+
// may carry one and a URL keeps it.
|
|
60
|
+
const LABEL_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
|
|
61
|
+
|
|
62
|
+
/** The empty file `init` scaffolds. */
|
|
63
|
+
export const EMPTY_MODEL_ENDPOINTS = `${JSON.stringify({ version: MODEL_ENDPOINTS_VERSION, endpoints: [] }, null, 2)}\n`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* VALKEY_URL's port, the queue's. An endpoint on it could reach the queue on a host where Valkey listens on the
|
|
67
|
+
* address the endpoint names. `null` when the URL does not parse (config's own checks refuse that elsewhere).
|
|
68
|
+
*/
|
|
69
|
+
export function valkeyPortOf(valkeyUrl) {
|
|
70
|
+
if (typeof valkeyUrl !== "string" || valkeyUrl === "") return null;
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(valkeyUrl);
|
|
73
|
+
return url.port === "" ? 6379 : Number(url.port);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A declared host, checked and put in the one spelling squid and a WHATWG URL both use. Returns the canonical host
|
|
81
|
+
* or throws a message (the caller adds the position). Three kinds:
|
|
82
|
+
*
|
|
83
|
+
* - an IPv4 literal in dotted-decimal form (`net.isIPv4`, which refuses `010.1.1.1` and `127.1`);
|
|
84
|
+
* - an IPv6 literal, bare or in brackets, stored in BRACKETS and compressed lowercase (`[fd00::2]`). Measured on
|
|
85
|
+
* squid 6.13, 2026-09-30: squid canonicalises a CONNECT's IPv6 host to that bracketed form before `dstdomain`
|
|
86
|
+
* compares it, so `dstdomain -n [fd00::2]` matches `[fd00:0:0::2]` and `[FD00::2]`, and a bare `fd00::2`
|
|
87
|
+
* matches nothing;
|
|
88
|
+
* - a DNS name, lowercased. A trailing dot is REFUSED rather than stripped: refuse, never repair, and a URL keeps
|
|
89
|
+
* the dot, so a stripped declaration would not match the baseUrl it was written for.
|
|
90
|
+
*/
|
|
91
|
+
function canonicalHost(raw) {
|
|
92
|
+
if (typeof raw !== "string" || raw === "") throw new Error("host must be a non-empty string");
|
|
93
|
+
if (raw !== raw.trim()) throw new Error("host must not carry spaces");
|
|
94
|
+
const lower = raw.toLowerCase();
|
|
95
|
+
// `name:port` or `[v6]:port`, the shape a URL writes, caught before the IPv6 test would call it a bad address. The
|
|
96
|
+
// port may be empty (`a.lan:`): that is still a host with a port separator, not an IPv6 address with an IPv4 tail.
|
|
97
|
+
if (/^[^:[\]]+:[0-9]*$/.test(lower) || /^\[[^\]]*\]:[0-9]*$/.test(lower)) {
|
|
98
|
+
throw new Error(`host ${JSON.stringify(raw)} carries a port: put the host alone in "host" and the port in "port"`);
|
|
99
|
+
}
|
|
100
|
+
if (isIPv4(lower)) {
|
|
101
|
+
if (isProxyLocalHost("ipv4", lower)) {
|
|
102
|
+
const why =
|
|
103
|
+
lower === "0.0.0.0"
|
|
104
|
+
? "the unspecified address, which is loopback on Linux"
|
|
105
|
+
: lower.startsWith("127.")
|
|
106
|
+
? "a loopback address, which inside the proxy is the proxy itself: declare the host's own address or host.docker.internal"
|
|
107
|
+
: "slirp4netns's host alias, which the proxy denies as host-local: declare host.containers.internal";
|
|
108
|
+
throw new Error(`host ${JSON.stringify(raw)} is ${why}`);
|
|
109
|
+
}
|
|
110
|
+
return lower;
|
|
111
|
+
}
|
|
112
|
+
if (lower.includes(":") || lower.startsWith("[")) return canonicalIPv6(raw, lower);
|
|
113
|
+
return canonicalName(raw, lower);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function canonicalIPv6(raw, lower) {
|
|
117
|
+
const inner = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
118
|
+
if (inner.includes("%")) throw new Error(`host ${JSON.stringify(raw)} carries a zone id, which a URL cannot hold`);
|
|
119
|
+
if (inner.includes(".")) throw new Error(`host ${JSON.stringify(raw)} is an IPv6 address with an IPv4 tail: declare the IPv4 address itself`);
|
|
120
|
+
let host;
|
|
121
|
+
try {
|
|
122
|
+
host = new URL(`http://[${inner}]/`).hostname;
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error(`host ${JSON.stringify(raw)} is not a DNS name, an IPv4 address or an IPv6 address`);
|
|
125
|
+
}
|
|
126
|
+
// The first 96 bits zero: `::`, `::1`, and the deprecated IPv4-compatible block, whose text squid and a URL spell
|
|
127
|
+
// differently (inet_ntop writes `::1.2.3.4`, a URL `::102:304`), so a rule for it would never match.
|
|
128
|
+
if (isProxyLocalHost("ipv6", host.slice(1, -1)) || /^\[::(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4})?)?\]$/.test(host)) {
|
|
129
|
+
throw new Error(`host ${JSON.stringify(raw)} is a loopback, unspecified or IPv4-compatible IPv6 address`);
|
|
130
|
+
}
|
|
131
|
+
// IPv4-mapped (::ffff:0:0/96): the same spelling split, and it is an IPv4 address anyway.
|
|
132
|
+
if (/^\[::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}\]$/.test(host)) {
|
|
133
|
+
throw new Error(`host ${JSON.stringify(raw)} is an IPv4-mapped IPv6 address: declare the IPv4 address itself`);
|
|
134
|
+
}
|
|
135
|
+
return host;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function canonicalName(raw, lower) {
|
|
139
|
+
if (lower.endsWith(".")) throw new Error(`host ${JSON.stringify(raw)} ends in a dot: write it without the dot, as the baseUrl in models.json does`);
|
|
140
|
+
if (lower.length > 253) throw new Error(`host ${JSON.stringify(raw)} is longer than 253 characters`);
|
|
141
|
+
const labels = lower.split(".");
|
|
142
|
+
for (const label of labels) {
|
|
143
|
+
if (!LABEL_RE.test(label)) throw new Error(`host ${JSON.stringify(raw)} is not a DNS name, an IPv4 address or an IPv6 address`);
|
|
144
|
+
}
|
|
145
|
+
// A URL reads a host whose last label is a number (`127.1`, `10.0x10`) as an IPv4 address, so such a "name"
|
|
146
|
+
// would reach an address nobody declared.
|
|
147
|
+
const last = labels[labels.length - 1];
|
|
148
|
+
if (/^[0-9]+$/.test(last) || /^0x[0-9a-f]*$/.test(last)) {
|
|
149
|
+
throw new Error(`host ${JSON.stringify(raw)} is neither a DNS name nor an IPv4 address in dotted-decimal form`);
|
|
150
|
+
}
|
|
151
|
+
if (isProxyLocalHost("name", lower)) {
|
|
152
|
+
throw new Error(`host ${JSON.stringify(raw)} is loopback, which inside the proxy is the proxy itself: declare the host's own address or host.docker.internal`);
|
|
153
|
+
}
|
|
154
|
+
return lower;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse, validate and normalise the endpoints file TEXT. Returns the endpoints as explicit
|
|
159
|
+
* `{ id, host, port, slots, keyless }` literals in file order. Throws `configError` on anything malformed, naming
|
|
160
|
+
* the position. `path` is for messages only; `valkeyPort` is the queue's port, refused as an endpoint port.
|
|
161
|
+
*/
|
|
162
|
+
export function parseModelEndpoints(text, path, { valkeyPort = null } = {}) {
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(text);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw configError(`model-endpoints file is not valid JSON: ${path} (${error.message})`);
|
|
168
|
+
}
|
|
169
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
170
|
+
throw configError(`model-endpoints file must be an object with "version" and "endpoints": ${path}`);
|
|
171
|
+
}
|
|
172
|
+
const extra = Object.keys(parsed).filter((k) => !FILE_KEYS.has(k));
|
|
173
|
+
if (extra.length > 0) throw configError(`model-endpoints file has unknown key(s) ${extra.map((k) => JSON.stringify(k)).join(", ")}: ${path}`);
|
|
174
|
+
const version = parsed.version;
|
|
175
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
176
|
+
throw configError(`model-endpoints file must have "version": 1 (an integer >= 1): ${path}`);
|
|
177
|
+
}
|
|
178
|
+
if (version > MODEL_ENDPOINTS_VERSION) {
|
|
179
|
+
throw configError(`model-endpoints file written by a newer pi-dispatch (version ${version}; this build understands ${MODEL_ENDPOINTS_VERSION}): ${path}`);
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(parsed.endpoints)) throw configError(`model-endpoints file must have an "endpoints" array: ${path}`);
|
|
182
|
+
const endpoints = parsed.endpoints.map((row, index) => normalizeEndpoint(row, index, path, valkeyPort));
|
|
183
|
+
const ids = new Map();
|
|
184
|
+
const pairs = new Map();
|
|
185
|
+
endpoints.forEach((e, index) => {
|
|
186
|
+
if (ids.has(e.id)) throw configError(`model endpoint at index ${index}: duplicate id ${JSON.stringify(e.id)} (first at index ${ids.get(e.id)}): ${path}`);
|
|
187
|
+
ids.set(e.id, index);
|
|
188
|
+
// Two ids for one server would split its slots in two, so each id would admit its own share and the server
|
|
189
|
+
// would get both.
|
|
190
|
+
const pair = `${e.host} ${e.port}`;
|
|
191
|
+
if (pairs.has(pair)) throw configError(`model endpoint at index ${index}: ${e.host} port ${e.port} is already declared as ${JSON.stringify(endpoints[pairs.get(pair)].id)}: ${path}`);
|
|
192
|
+
pairs.set(pair, index);
|
|
193
|
+
});
|
|
194
|
+
return endpoints;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeEndpoint(row, index, path, valkeyPort) {
|
|
198
|
+
const at = `model endpoint at index ${index}`;
|
|
199
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) throw configError(`${at}: must be an object: ${path}`);
|
|
200
|
+
const extra = Object.keys(row).filter((k) => !ENDPOINT_KEYS.has(k));
|
|
201
|
+
if (extra.length > 0) throw configError(`${at}: unknown key(s) ${extra.map((k) => JSON.stringify(k)).join(", ")} (known: id, host, port, slots, keyless): ${path}`);
|
|
202
|
+
if (typeof row.id !== "string" || !ID_RE.test(row.id)) throw configError(`${at}: id must be 1 to 32 of a-z, 0-9 and -: ${path}`);
|
|
203
|
+
const label = `model endpoint ${JSON.stringify(row.id)}`;
|
|
204
|
+
let host;
|
|
205
|
+
try {
|
|
206
|
+
host = canonicalHost(row.host);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw configError(`${label}: ${err.message}: ${path}`);
|
|
209
|
+
}
|
|
210
|
+
const port = row.port;
|
|
211
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw configError(`${label}: port must be an integer from 1 to 65535: ${path}`);
|
|
212
|
+
if (port === PROXY_PORT) throw configError(`${label}: port ${PROXY_PORT} is the egress proxy's own port: ${path}`);
|
|
213
|
+
if (valkeyPort !== null && port === valkeyPort) throw configError(`${label}: port ${port} is the job queue's (VALKEY_URL), which a job must never reach: ${path}`);
|
|
214
|
+
const slots = row.slots;
|
|
215
|
+
if (!Number.isInteger(slots) || slots < 1 || slots > MAX_SLOTS) throw configError(`${label}: slots must be an integer from 1 to ${MAX_SLOTS}: ${path}`);
|
|
216
|
+
const keyless = row.keyless === undefined ? false : row.keyless;
|
|
217
|
+
if (typeof keyless !== "boolean") throw configError(`${label}: keyless must be true or false: ${path}`);
|
|
218
|
+
return { id: row.id, host, port, slots, keyless };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The endpoints file's path and whether it was named. `PI_MODEL_ENDPOINTS_FILE` (config.modelEndpointsFile) wins;
|
|
223
|
+
* unset, it is `model-endpoints.json` in the deployment folder (`cwd`), the folder `init` scaffolds it in.
|
|
224
|
+
*/
|
|
225
|
+
export function modelEndpointsPath(config, cwd = process.cwd()) {
|
|
226
|
+
const named = config?.modelEndpointsFile;
|
|
227
|
+
if (named === null || named === undefined) return { path: join(cwd, MODEL_ENDPOINTS_FILE_NAME), explicit: false };
|
|
228
|
+
return { path: named === "" ? "" : resolve(cwd, named), explicit: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Load and validate the endpoints file. A missing DEFAULT file is `[]` (no endpoints, a valid deployment); a
|
|
233
|
+
* missing file that PI_MODEL_ENDPOINTS_FILE names is a `configError`, since a typo there would silently declare
|
|
234
|
+
* nothing. An empty value is named, and refused the same way. fs and cwd are injectable for tests.
|
|
235
|
+
*/
|
|
236
|
+
export function loadModelEndpoints(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, cwd = process.cwd() } = {}) {
|
|
237
|
+
const { path, explicit } = modelEndpointsPath(config, cwd);
|
|
238
|
+
if (explicit && path === "") throw configError("PI_MODEL_ENDPOINTS_FILE is set to an empty value: name the file, or remove the line to use model-endpoints.json in the deployment folder");
|
|
239
|
+
if (!existsSync(path)) {
|
|
240
|
+
if (!explicit) return [];
|
|
241
|
+
throw configError(`model-endpoints file does not exist: ${path}`);
|
|
242
|
+
}
|
|
243
|
+
return parseModelEndpoints(String(readFileSync(path, "utf8")), path, { valkeyPort: valkeyPortOf(config?.valkeyUrl) });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The errnos a read of the overlay `models.json` reads as NO FILE, the way the job sees it (PR #553's review). The
|
|
248
|
+
* runner picks the overlay with `existsSync("/opt/pi-global/models.json")` (image/runner/run-job.mjs) and pi reads
|
|
249
|
+
* no overlay at all when that answers false, with no error. With `models.json` itself never a link (below), ELOOP,
|
|
250
|
+
* ENOTDIR and ENAMETOOLONG can come only from the folder's own path, and there is no file content to lose.
|
|
251
|
+
*/
|
|
252
|
+
const OVERLAY_ABSENT_CODES = new Set(["ENOENT", "ELOOP", "ENOTDIR", "ENAMETOOLONG"]);
|
|
253
|
+
|
|
254
|
+
/** The fixed text of the refusal for a `models.json` that is a link: what to do, and why. */
|
|
255
|
+
export const OVERLAY_LINK_FIX = "models.json in the overlay folder is a link; replace it with the file itself (the job's read-only mount cannot follow links reliably)";
|
|
256
|
+
|
|
257
|
+
/** The determinate fault of a `models.json` that is a link of any kind; `overlayLink` marks it. */
|
|
258
|
+
function overlayLinkError(path) {
|
|
259
|
+
const error = configError(`${OVERLAY_LINK_FIX}: ${path}`);
|
|
260
|
+
error.overlayLink = true;
|
|
261
|
+
return error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The fixed text of the refusal for a `models.json` that is a FIFO, socket or device: what to do, and why. */
|
|
265
|
+
export const OVERLAY_NOT_A_FILE_FIX = "models.json in the overlay folder is not a regular file (a named pipe, socket or device); replace it with the file itself (reading one can block, and pi in the job cannot load it)";
|
|
266
|
+
|
|
267
|
+
/** The determinate fault of a `models.json` that is neither a regular file nor a folder; `overlayNotAFile` marks it. */
|
|
268
|
+
function overlayNotAFileError(path) {
|
|
269
|
+
const error = configError(`${OVERLAY_NOT_A_FILE_FIX}: ${path}`);
|
|
270
|
+
error.overlayNotAFile = true;
|
|
271
|
+
return error;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The overlay's `models.json` (`<globalPiDir>/models.json`), parsed as JSON and nothing else: no `$VAR` expansion,
|
|
276
|
+
* because the host's environment is not the job's, and a derivation that expanded one would describe a server the
|
|
277
|
+
* job never dials. ONE rule for every caller (the pickup snapshot in index.mjs, doctor), judged as the JOB sees the
|
|
278
|
+
* file (PR #553's review), in five outcomes:
|
|
279
|
+
* - `null` when the job has no overlay: `models.json` is missing, or the folder's path fails with ELOOP, ENOTDIR or
|
|
280
|
+
* ENAMETOOLONG, which the runner's `existsSync` (image/runner/run-job.mjs) answers false for;
|
|
281
|
+
* - a `configError` marked `overlayLink` when `models.json` is a link of ANY kind, dangling included (`lstat`). The
|
|
282
|
+
* job's read-only mount resolves a link differently from the host (an absolute target, a target outside the
|
|
283
|
+
* folder, a trailing `/` or a `..` through a file each differ), and two rounds of following links the way the
|
|
284
|
+
* mount does kept finding cases, so the rule is the simple one: the file itself, never a link. The overlay FOLDER
|
|
285
|
+
* may be a link: the runtime follows it when it mounts the folder, and both sides then read the same file;
|
|
286
|
+
* - a `configError` marked `overlayNotAFile` when `models.json` is a named pipe, a socket or a device (issue #556),
|
|
287
|
+
* judged from the same `lstat` and never opened: a FIFO with no writer blocks `readFileSync` forever, on every
|
|
288
|
+
* pickup and in doctor. A folder is not this case: its read fails at once with EISDIR, rethrown below;
|
|
289
|
+
* - ANY other fs error is RETHROWN as-is, its `code` intact (EACCES, EPERM, EISDIR, EIO...), so a caller can tell
|
|
290
|
+
* it from a verdict. The caller classifies the code: `isTransientOverlayRead` (model-catalog.mjs) names the few
|
|
291
|
+
* that may pass, and in the job pi loads none of the file for all the rest (the runner's existence check, or
|
|
292
|
+
* pi's own read, fails);
|
|
293
|
+
* - text pi would not load is a `configError`: a determinate fault the operator fixes. "Would not load" is pi's own
|
|
294
|
+
* rule since issue #502 (`parseModelsJson`): a BOM, `//` comments and trailing commas are fine, and a schema
|
|
295
|
+
* error anywhere in the document refuses all of it, because pi drops the whole file over one.
|
|
296
|
+
* The read is NOT inside the parse's try, and there is no `existsSync` first. Both were here (PR #520 round 2): the
|
|
297
|
+
* try turned EACCES and EIO into "not valid JSON", and `existsSync` answers false for a file under an unreadable
|
|
298
|
+
* directory, which read as absence. A `lstat` or read that throws is the only honest question.
|
|
299
|
+
*/
|
|
300
|
+
export function readOverlayModels(globalPiDir, { readFileSync = fsReadFileSync, lstatSync = fsLstatSync } = {}) {
|
|
301
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return null;
|
|
302
|
+
const path = join(globalPiDir, "models.json");
|
|
303
|
+
let text;
|
|
304
|
+
try {
|
|
305
|
+
const entry = lstatSync(path);
|
|
306
|
+
if (entry.isSymbolicLink()) throw overlayLinkError(path);
|
|
307
|
+
if (!entry.isFile() && !entry.isDirectory()) throw overlayNotAFileError(path);
|
|
308
|
+
text = String(readFileSync(path, "utf8"));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (OVERLAY_ABSENT_CODES.has(error?.code)) return null;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
// Parsed the way pi parses it (issue #502, `models-json.mjs`): a BOM, `//` comments and trailing commas are
|
|
314
|
+
// accepted, and a document pi's schema refuses anywhere is refused whole, because pi then loads none of it.
|
|
315
|
+
const { value, error } = parseModelsJson(text);
|
|
316
|
+
if (error !== undefined) throw configError(`overlay models.json ${error}: ${path}`);
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The `host` and `port` a baseUrl dials, as a URL reads it: lowercase hostname (an IPv6 one in brackets), the
|
|
322
|
+
* scheme's default port when none is written. `null` for anything that is not an http(s) URL.
|
|
323
|
+
*/
|
|
324
|
+
export function baseUrlTarget(baseUrl) {
|
|
325
|
+
if (typeof baseUrl !== "string" || baseUrl === "") return null;
|
|
326
|
+
let url;
|
|
327
|
+
try {
|
|
328
|
+
url = new URL(baseUrl);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
333
|
+
const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port);
|
|
334
|
+
// ONE trailing dot is stripped here, while a declaration with one is refused. squid tunnels `name.` (measured 200 on
|
|
335
|
+
// 2026-09-30) to the same server as `name`, so a baseUrl spelled with the dot reaches the endpoint, and matching
|
|
336
|
+
// nothing would let it skip the endpoint's slot lease and keyless gate. The declaration stays strict: refuse, never
|
|
337
|
+
// repair, in the file the operator writes.
|
|
338
|
+
const host = url.hostname.endsWith(".") ? url.hostname.slice(0, -1) : url.hostname;
|
|
339
|
+
return { host, port };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function providerOf(models, provider) {
|
|
343
|
+
const providers = models?.providers;
|
|
344
|
+
if (providers === null || typeof providers !== "object" || Array.isArray(providers)) return null;
|
|
345
|
+
if (!Object.hasOwn(providers, provider)) return null;
|
|
346
|
+
const entry = providers[provider];
|
|
347
|
+
return entry !== null && typeof entry === "object" && !Array.isArray(entry) ? entry : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function modelsOf(entry) {
|
|
351
|
+
return Array.isArray(entry?.models) ? entry.models.filter((m) => m !== null && typeof m === "object" && typeof m.id === "string") : [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A model's overlay pieces (issue #507, output-cap.mjs): its provider entry, its own definition and its `modelOverrides`
|
|
356
|
+
* entry, each null when absent. Own keys only.
|
|
357
|
+
*/
|
|
358
|
+
export function modelEntryOf(models, provider, modelId) {
|
|
359
|
+
const entry = providerOf(models, provider);
|
|
360
|
+
const defined = entry === null ? null : (modelsOf(entry).find((m) => m.id === modelId) ?? null);
|
|
361
|
+
const overrides = entry?.modelOverrides;
|
|
362
|
+
const override = overrides !== null && typeof overrides === "object" && !Array.isArray(overrides) && Object.hasOwn(overrides, modelId) ? overrides[modelId] : null;
|
|
363
|
+
return { entry, defined, override: override !== null && typeof override === "object" ? override : null };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function matching(baseUrl, endpoints) {
|
|
367
|
+
const target = baseUrlTarget(baseUrl);
|
|
368
|
+
if (target === null || !Array.isArray(endpoints)) return [];
|
|
369
|
+
return endpoints.filter((e) => e.host === target.host && e.port === target.port);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The declared endpoints a model uses: those whose host AND port its effective baseUrl dials. The effective baseUrl
|
|
374
|
+
* is the model's own `baseUrl`, else its provider's. A model the overlay does not list takes the provider's. `[]`
|
|
375
|
+
* when nothing matches, including when there is no overlay or no such provider. One endpoint at most, since a host
|
|
376
|
+
* and port pair is declared once.
|
|
377
|
+
*/
|
|
378
|
+
export function endpointsForModel({ models, provider, modelId, endpoints }) {
|
|
379
|
+
const entry = providerOf(models, provider);
|
|
380
|
+
if (entry === null) return [];
|
|
381
|
+
const model = modelsOf(entry).find((m) => m.id === modelId);
|
|
382
|
+
const baseUrl = typeof model?.baseUrl === "string" ? model.baseUrl : entry.baseUrl;
|
|
383
|
+
return matching(baseUrl, endpoints);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Every model a provider lists in the overlay, each with the endpoints it uses, in the overlay's order. For a
|
|
388
|
+
* question about the whole provider ("is every one of its models on a keyless endpoint"); `[]` when the provider
|
|
389
|
+
* lists no models.
|
|
390
|
+
*/
|
|
391
|
+
export function endpointsForProvider({ models, provider, endpoints }) {
|
|
392
|
+
const entry = providerOf(models, provider);
|
|
393
|
+
if (entry === null) return [];
|
|
394
|
+
return modelsOf(entry).map((m) => ({ modelId: m.id, endpoints: matching(typeof m.baseUrl === "string" ? m.baseUrl : entry.baseUrl, endpoints) }));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The cost table pi would bill one model by, as the overlay composes it (issue #503 part 7), or `null` when that
|
|
399
|
+
* cannot be told. pi 0.99.1's provider composer (`pi-coding-agent/dist/core/provider-composer.js`) builds it in two
|
|
400
|
+
* steps, mirrored here:
|
|
401
|
+
* 1. the BASE: a model the overlay's `providers.<p>.models` defines takes that entry's `cost`, and an entry with no
|
|
402
|
+
* `cost` gets all zeros (`modelFromJson`: "an absent cost table counts as zero"), whatever the builtin catalog
|
|
403
|
+
* says, because the overlay entry REPLACES the builtin model. Any other model takes the builtin catalog's cost
|
|
404
|
+
* (`builtinModel(provider, id)`, injected so this module never imports pi). Neither: `null`;
|
|
405
|
+
* 2. the OVERRIDE: `modelOverrides.<id>.cost`, field by field over the base (`applyModelOverride`), and only on a
|
|
406
|
+
* chat model (pi skips the override on an image or classifier model). The overlay's own models are chat models.
|
|
407
|
+
*/
|
|
408
|
+
export function composedCost({ models, provider, modelId, builtinModel }) {
|
|
409
|
+
const entry = providerOf(models, provider);
|
|
410
|
+
const defined = modelsOf(entry).find((m) => m.id === modelId);
|
|
411
|
+
let base;
|
|
412
|
+
let chat = true;
|
|
413
|
+
if (defined !== undefined) {
|
|
414
|
+
base = defined.cost === undefined ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : defined.cost;
|
|
415
|
+
} else {
|
|
416
|
+
const builtin = typeof builtinModel === "function" ? builtinModel(provider, modelId) : null;
|
|
417
|
+
if (builtin === null || builtin === undefined || typeof builtin !== "object") return null;
|
|
418
|
+
base = builtin.cost;
|
|
419
|
+
chat = (builtin.type ?? "chat") === "chat";
|
|
420
|
+
}
|
|
421
|
+
if (base === null || typeof base !== "object" || Array.isArray(base)) return null;
|
|
422
|
+
const overrides = entry?.modelOverrides;
|
|
423
|
+
const override = chat && overrides !== null && typeof overrides === "object" && !Array.isArray(overrides) && Object.hasOwn(overrides, modelId) ? overrides[modelId] : undefined;
|
|
424
|
+
const cost = override !== null && typeof override === "object" ? override.cost : undefined;
|
|
425
|
+
if (cost === undefined || cost === null) return base;
|
|
426
|
+
if (typeof cost !== "object" || Array.isArray(cost)) return null;
|
|
427
|
+
return { input: cost.input ?? base.input, output: cost.output ?? base.output, cacheRead: cost.cacheRead ?? base.cacheRead, cacheWrite: cost.cacheWrite ?? base.cacheWrite, tiers: cost.tiers ?? base.tiers };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
431
|
+
|
|
432
|
+
/** Every rate of a cost table and of each of its tiers is exactly 0. A rate that is absent or not a number is not 0. */
|
|
433
|
+
export function isZeroCost(cost) {
|
|
434
|
+
if (cost === null || typeof cost !== "object") return false;
|
|
435
|
+
if (!RATE_KEYS.every((k) => cost[k] === 0)) return false;
|
|
436
|
+
if (cost.tiers === undefined || cost.tiers === null) return true;
|
|
437
|
+
return Array.isArray(cost.tiers) && cost.tiers.every((t) => t !== null && typeof t === "object" && RATE_KEYS.every((k) => t[k] === 0));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Can this job spend nothing, so that a dollar window needs to hold nothing for it (issue #503 part 7, issue #501)?
|
|
442
|
+
* `{ zeroRated: true }` only when EVERY model the job may call is BOTH:
|
|
443
|
+
* - served by a declared endpoint (`endpointsForModel`): a local or LAN server the operator runs, not a hosted
|
|
444
|
+
* provider that bills; and
|
|
445
|
+
* - zero-rated as pi would compose it (`composedCost`: the overlay entry's cost, else the builtin's, then the
|
|
446
|
+
* override): every rate of every table 0.
|
|
447
|
+
* Else `{ zeroRated: false, why }`, a fixed phrase naming the first model that is not (for the log, never a comment).
|
|
448
|
+
*
|
|
449
|
+
* "Every model the job may call" is `refs`: the job's effective allowed-model list when it has one (issue #502), else
|
|
450
|
+
* its main model alone. With a list, the MAIN model alone is not enough: the job may switch to any listed model, and
|
|
451
|
+
* a hosted one would then spend with nothing reserved. Without a list a mid-run switch to another model is the named
|
|
452
|
+
* residual of #503 (an unrestricted job's endpoint set is its main model's), and the runner still holds such a job to
|
|
453
|
+
* a per-job cap of 0, which refuses every priced call before it is made.
|
|
454
|
+
*
|
|
455
|
+
* `models` null (no overlay, or one that could not be read) or no endpoints is never zero-rated: fail closed, and the
|
|
456
|
+
* job reserves as usual. Pure: it reads only what it is handed, so the processor and any later reader agree by input.
|
|
457
|
+
*/
|
|
458
|
+
export function zeroRatedVerdict({ models, endpoints, refs, builtinModel = () => null }) {
|
|
459
|
+
if (!Array.isArray(refs) || refs.length === 0) return { zeroRated: false, why: "no model to judge" };
|
|
460
|
+
if (models === null || models === undefined || !Array.isArray(endpoints) || endpoints.length === 0) return { zeroRated: false, why: "no declared endpoint or no overlay models.json" };
|
|
461
|
+
for (const ref of refs) {
|
|
462
|
+
if (ref === null || typeof ref?.provider !== "string" || typeof ref?.id !== "string") return { zeroRated: false, why: "a list entry is not provider/model" };
|
|
463
|
+
const label = `${ref.provider}/${ref.id}`;
|
|
464
|
+
if (endpointsForModel({ models, provider: ref.provider, modelId: ref.id, endpoints }).length === 0) return { zeroRated: false, why: `${label} is not served by a declared endpoint` };
|
|
465
|
+
if (!isZeroCost(composedCost({ models, provider: ref.provider, modelId: ref.id, builtinModel }))) return { zeroRated: false, why: `${label} is not zero-rated` };
|
|
466
|
+
}
|
|
467
|
+
return { zeroRated: true };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The models the overlay makes report no usage while they cost money (issue #571), as `[{ provider, modelId }]`: the
|
|
472
|
+
* calls on such a model reach the meter with pi's zero usage, so it counts every one as `costUnreported` and the job
|
|
473
|
+
* settles at the floor. pi's openai-completions api asks for usage in the stream unless the model's composed
|
|
474
|
+
* `compat.supportsUsageInStreaming` is `false`, and most servers then send none. Composed as pi 0.99.1's
|
|
475
|
+
* provider-composer.js does:
|
|
476
|
+
* - a model the overlay DEFINES (`providers.<p>.models`): the provider's `compat`, then the model's own, then its
|
|
477
|
+
* `modelOverrides` entry, each key overriding the one before; its cost is composedCost's;
|
|
478
|
+
* - a BUILTIN chat model of that provider the overlay does not redefine (`builtinChatModels(provider)`, injected so
|
|
479
|
+
* this module never imports pi): the catalog's compat, then the provider's `compat`, then the `modelOverrides`
|
|
480
|
+
* entry; only when the overlay itself sets the flag `false` (the catalog's own value is pi's, not the operator's);
|
|
481
|
+
* its cost is the catalog's with the override's applied.
|
|
482
|
+
* A model whose api is set to another api is skipped, since only openai-completions reads the flag; a defined model
|
|
483
|
+
* with no api of its own or its provider's is kept. In the overlay's order, defined models before builtin ones.
|
|
484
|
+
*/
|
|
485
|
+
export function unreportedUsageModels(models, { builtinModel = () => null, builtinChatModels = () => [] } = {}) {
|
|
486
|
+
const providers = models?.providers;
|
|
487
|
+
if (providers === null || typeof providers !== "object" || Array.isArray(providers)) return [];
|
|
488
|
+
const found = [];
|
|
489
|
+
const flag = (compat) => (compat !== null && typeof compat === "object" ? compat.supportsUsageInStreaming : undefined);
|
|
490
|
+
for (const provider of Object.keys(providers)) {
|
|
491
|
+
const entry = providerOf(models, provider);
|
|
492
|
+
if (entry === null) continue;
|
|
493
|
+
const overrides = entry.modelOverrides !== null && typeof entry.modelOverrides === "object" && !Array.isArray(entry.modelOverrides) ? entry.modelOverrides : {};
|
|
494
|
+
const overrideOf = (id) => (Object.hasOwn(overrides, id) ? overrides[id] : undefined);
|
|
495
|
+
const defined = modelsOf(entry);
|
|
496
|
+
for (const model of defined) {
|
|
497
|
+
const api = model.api ?? entry.api;
|
|
498
|
+
if (typeof api === "string" && api !== "openai-completions") continue;
|
|
499
|
+
const usage = flag(overrideOf(model.id)?.compat) ?? flag(model.compat) ?? flag(entry.compat);
|
|
500
|
+
if (usage !== false) continue;
|
|
501
|
+
const cost = composedCost({ models, provider, modelId: model.id, builtinModel });
|
|
502
|
+
if (cost !== null && isZeroCost(cost)) continue;
|
|
503
|
+
found.push({ provider, modelId: model.id });
|
|
504
|
+
}
|
|
505
|
+
const listed = typeof builtinChatModels === "function" ? builtinChatModels(provider) : [];
|
|
506
|
+
for (const builtin of Array.isArray(listed) ? listed : []) {
|
|
507
|
+
if (builtin === null || typeof builtin !== "object" || typeof builtin.id !== "string") continue;
|
|
508
|
+
if (defined.some((m) => m.id === builtin.id) || builtin.api !== "openai-completions") continue;
|
|
509
|
+
if ((flag(overrideOf(builtin.id)?.compat) ?? flag(entry.compat)) !== false) continue;
|
|
510
|
+
const cost = composedCost({ models, provider, modelId: builtin.id, builtinModel: () => builtin });
|
|
511
|
+
if (cost !== null && isZeroCost(cost)) continue;
|
|
512
|
+
found.push({ provider, modelId: builtin.id });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return found;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The exact `apiKey` a keyless provider's models.json entry carries: pi resolves `$NAME` from the job's environment. */
|
|
519
|
+
export const KEYLESS_API_KEY = `$${KEYLESS_ENV_NAME}`;
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The second way into the credential gate, said once for every refusal and doctor line that names it (issue #503).
|
|
523
|
+
*/
|
|
524
|
+
export const KEYLESS_HOW = `for a custom provider served by a local model server, declare that server in model-endpoints.json with "keyless": true and set "apiKey": "${KEYLESS_API_KEY}" on the provider in models.json (docs/egress.md, "Local model servers")`;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Is this overlay provider keyless (issue #503, part 4)? `{ keyless: true, endpoints: [ids] }` only when ALL of these
|
|
528
|
+
* hold, else `{ keyless: false, why }` (`why` null when the overlay does not define the provider at all):
|
|
529
|
+
* - the overlay `models.json` defines the provider and lists at least one model;
|
|
530
|
+
* - EVERY one of its models (effective baseUrl) is served by a declared endpoint, and every such endpoint is
|
|
531
|
+
* `keyless: true`. EVERY and not SOME: the job may switch to any model of its provider, and one model on a hosted
|
|
532
|
+
* server would then run with no key the gate ever looked at;
|
|
533
|
+
* - its `apiKey` is exactly `"$PI_DISPATCH_KEYLESS"`. pi composes a models.json provider only with SOME key (at 0.99.1,
|
|
534
|
+
* `provider-composer.js` "no authentication method configured"), and the worker sets that one variable, fixed and
|
|
535
|
+
* non-secret, on this branch alone. Any other value means pi sends something the gate never saw: a literal key (a
|
|
536
|
+
* secret in a mounted file, which `import-pi` already refuses), another `$VAR` (unset in the closed container env,
|
|
537
|
+
* so the runner exits 2 after the container started), or `!cmd` (a shell in the job). Refused rather than guessed;
|
|
538
|
+
* - it carries NO other credential of any kind: no `headers` on the provider, on any model or in any
|
|
539
|
+
* `modelOverrides` entry, no `oauth`, and no userinfo (`user:pass@`) in the provider's or any model's baseUrl.
|
|
540
|
+
* Keyless means no credentials, and this is the simple rule rather than a judgement of which header looks like a
|
|
541
|
+
* secret. Nothing of it would reach a hosted service (every model must be on a declared endpoint), but a header
|
|
542
|
+
* value is a pi config value too (`!cmd` runs a shell, `$VAR` is unset in the closed env), and a secret in the
|
|
543
|
+
* overlay is a secret in a mounted file;
|
|
544
|
+
* - every model entry is an object with a non-empty string `id`. pi validates models.json strictly and refuses the
|
|
545
|
+
* WHOLE file over one bad entry, so skipping it here would pass a job whose runner then exits 2 in a container.
|
|
546
|
+
*
|
|
547
|
+
* The overlay is read the way pi reads it (`readOverlayModels`, issue #502): a file pi loads (comments, a BOM, a
|
|
548
|
+
* trailing comma) is read here too, and a file pi drops is refused here, so nothing in it is keyless: fail closed.
|
|
549
|
+
*
|
|
550
|
+
* Whether pi itself knows the provider is NOT asked here: this module never imports pi. The callers ask that first,
|
|
551
|
+
* with the same predicate as the credential gate (`env-allowlist.mjs`), and only an unknown provider reaches this.
|
|
552
|
+
* Pure: it reads the snapshot it is handed and nothing else, so the gate, the env writer and doctor agree by input.
|
|
553
|
+
*/
|
|
554
|
+
export function keylessVerdict({ models, provider, endpoints }) {
|
|
555
|
+
const entry = providerOf(models, provider);
|
|
556
|
+
if (entry === null) return { keyless: false, why: null };
|
|
557
|
+
if (entry.apiKey !== KEYLESS_API_KEY) return { keyless: false, why: `its models.json "apiKey" is not "${KEYLESS_API_KEY}"` };
|
|
558
|
+
const credential = otherCredential(entry);
|
|
559
|
+
if (credential !== null) return { keyless: false, why: `it sets ${credential}, and keyless means no credentials of any kind` };
|
|
560
|
+
if (entry.models !== undefined && (!Array.isArray(entry.models) || entry.models.some((m) => m === null || typeof m !== "object" || Array.isArray(m) || typeof m.id !== "string" || m.id === ""))) {
|
|
561
|
+
return { keyless: false, why: 'a model entry in models.json is not an object with a non-empty string "id", so pi would refuse the file' };
|
|
562
|
+
}
|
|
563
|
+
const served = endpointsForProvider({ models, provider, endpoints });
|
|
564
|
+
if (served.length === 0) return { keyless: false, why: "it lists no models in models.json" };
|
|
565
|
+
const ids = new Set();
|
|
566
|
+
for (const { modelId, endpoints: used } of served) {
|
|
567
|
+
if (used.length === 0) return { keyless: false, why: `its model ${JSON.stringify(modelId)} is not served by a declared model endpoint` };
|
|
568
|
+
for (const e of used) {
|
|
569
|
+
if (e.keyless !== true) return { keyless: false, why: `the endpoint ${e.id} serving its model ${JSON.stringify(modelId)} is not "keyless": true` };
|
|
570
|
+
ids.add(e.id);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { keyless: true, endpoints: [...ids].sort() };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The first credential a keyless provider must not carry besides its apiKey, named for the refusal; null when none. */
|
|
577
|
+
function otherCredential(entry) {
|
|
578
|
+
if (entry.headers !== undefined) return "provider headers";
|
|
579
|
+
if (entry.oauth !== undefined) return '"oauth"';
|
|
580
|
+
if (hasUserinfo(entry.baseUrl)) return "a user or password in its baseUrl";
|
|
581
|
+
for (const m of Array.isArray(entry.models) ? entry.models : []) {
|
|
582
|
+
if (m === null || typeof m !== "object") continue;
|
|
583
|
+
if (m.headers !== undefined) return `headers on its model ${JSON.stringify(m.id)}`;
|
|
584
|
+
if (hasUserinfo(m.baseUrl)) return `a user or password in the baseUrl of its model ${JSON.stringify(m.id)}`;
|
|
585
|
+
}
|
|
586
|
+
const overrides = entry.modelOverrides;
|
|
587
|
+
if (overrides !== null && typeof overrides === "object") {
|
|
588
|
+
for (const [id, o] of Object.entries(overrides)) if (o !== null && typeof o === "object" && o.headers !== undefined) return `headers in modelOverrides ${JSON.stringify(id)}`;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function hasUserinfo(baseUrl) {
|
|
594
|
+
if (typeof baseUrl !== "string") return false;
|
|
595
|
+
try {
|
|
596
|
+
const url = new URL(baseUrl);
|
|
597
|
+
return url.username !== "" || url.password !== "";
|
|
598
|
+
} catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The loopback addresses a declared name must not resolve to: `to_host_local`'s set (deploy/egress-proxy.conf) minus
|
|
605
|
+
* 169.254.0.0/16 and fe80::/10. Those two are left out on purpose: on Podman 5.3 and later `host.containers.internal`
|
|
606
|
+
* is 169.254.1.2 (pasta's --map-guest-addr, measured 2026-09-30 on Podman 5.8.1), which is how a rootless job reaches a
|
|
607
|
+
* server on its own host. 10.0.2.2 (slirp4netns's host alias) stays, as in `to_host_local`. Built from
|
|
608
|
+
* `PROXY_LOCAL_ADDRESSES` in backends.mjs, the one source the parser's refusal and `hostRouteFor` read too.
|
|
609
|
+
*/
|
|
610
|
+
export const LOCAL_ADDRESSES = PROXY_LOCAL_ADDRESSES.join(" ");
|
|
611
|
+
|
|
612
|
+
/** The header every rendered include starts with. A comments-only file is one squid starts on (measured). */
|
|
613
|
+
export const ENDPOINTS_INCLUDE_HEADER = [
|
|
614
|
+
"# Generated by pi-dispatch from model-endpoints.json. Do not edit: regenerate it with `pi-dispatch egress render`.",
|
|
615
|
+
"# Each endpoint is a CONNECT tunnel to exactly one declared host and port, and nothing else (issue #503).",
|
|
616
|
+
"# The host ACL comes first on every line, so squid only ever resolves a declared name.",
|
|
617
|
+
].join("\n");
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The squid include for these endpoints: deterministic, sorted by id, header first. Per endpoint, a host ACL, a
|
|
621
|
+
* port ACL, a loopback ACL, a deny for the declared name when it resolves to loopback, and a CONNECT allow for the
|
|
622
|
+
* pair. No plain forward request is allowed: pi sends every provider call as a CONNECT tunnel.
|
|
623
|
+
*
|
|
624
|
+
* The host ACL is `dstdomain -n` for a name AND for an IP literal. Measured on squid 6.13, 2026-09-30: a `dst <ip>`
|
|
625
|
+
* ACL makes squid resolve the name of every CONNECT that reaches the line, a DNS channel out of the job, and admits
|
|
626
|
+
* any name that resolves to that address; `dstdomain -n <ip>` does neither.
|
|
627
|
+
*/
|
|
628
|
+
export function renderEndpointsInclude(endpoints) {
|
|
629
|
+
const sorted = [...(endpoints ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
630
|
+
const lines = [ENDPOINTS_INCLUDE_HEADER];
|
|
631
|
+
for (const e of sorted) {
|
|
632
|
+
// Parser output only. Checked again here because these lines are proxy rules: a newline in either would be a
|
|
633
|
+
// rule nobody declared.
|
|
634
|
+
if (typeof e.id !== "string" || !ID_RE.test(e.id)) throw new Error(`renderEndpointsInclude: bad id ${JSON.stringify(e.id)}`);
|
|
635
|
+
if (typeof e.host !== "string" || !/^[a-z0-9_.:[\]-]+$/.test(e.host)) throw new Error(`renderEndpointsInclude: bad host ${JSON.stringify(e.host)}`);
|
|
636
|
+
if (!Number.isInteger(e.port) || e.port < 1 || e.port > 65535) throw new Error(`renderEndpointsInclude: bad port ${JSON.stringify(e.port)}`);
|
|
637
|
+
const n = `pde_${e.id}`;
|
|
638
|
+
lines.push(
|
|
639
|
+
"",
|
|
640
|
+
`# ${e.id}`,
|
|
641
|
+
`acl ${n}_host dstdomain -n ${e.host}`,
|
|
642
|
+
`acl ${n}_port port ${e.port}`,
|
|
643
|
+
`acl ${n}_local dst ${LOCAL_ADDRESSES}`,
|
|
644
|
+
`http_access deny CONNECT ${n}_host ${n}_port ${n}_local`,
|
|
645
|
+
`http_access allow CONNECT ${n}_host ${n}_port`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return `${lines.join("\n")}\n`;
|
|
649
|
+
}
|