@edgehero/pi-dispatch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { materializePiDir } from "./materialize.mjs";
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prepare a LOCAL-FOLDER job. This is the zero-GitHub path: no token, no clone, no PR. The folder
|
|
11
|
+
* on the operator's own machine becomes /workspace (bind-mounted read-write), edited in place.
|
|
12
|
+
*
|
|
13
|
+
* For v1 the folder must be a git repository, which buys two things for free: a stable ref (HEAD)
|
|
14
|
+
* to read instructions from, and git's object model, so `.pi/` materialises through the same
|
|
15
|
+
* symlink/submodule-safe path as GitHub jobs (materializePiDir). Instructions come from HEAD
|
|
16
|
+
* (committed, reviewed); work happens on the working tree in /workspace. A non-git folder is a
|
|
17
|
+
* documented v1 limitation -- `git init` it first.
|
|
18
|
+
*
|
|
19
|
+
* The task text is DATA (CONST-ISSUE-TEXT-IS-DATA): it goes into /job/prompt.md, never the
|
|
20
|
+
* instructions. The operator supplies it via the CLI (`pi-dispatch run --task`).
|
|
21
|
+
*
|
|
22
|
+
* `event` is the trigger context the dispatcher derived (cron/manual/chain); it lands in
|
|
23
|
+
* /job/event.json (INT-CONTAINER-JOB-INPUTS) -- one file per concern, alongside prompt.md. The
|
|
24
|
+
* default keeps a directly-constructed call (tests, older wiring) honest: a job with no derived
|
|
25
|
+
* context is a manual run.
|
|
26
|
+
*/
|
|
27
|
+
export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaultGit, event = { source: "manual" } }) {
|
|
28
|
+
if (!existsSync(folder)) {
|
|
29
|
+
const error = new Error(`local folder does not exist: ${folder}`);
|
|
30
|
+
error.piDispatchConfig = true;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (!existsSync(join(folder, ".git"))) {
|
|
34
|
+
const error = new Error(`local folder is not a git repository (v1 requires one): ${folder}`);
|
|
35
|
+
error.piDispatchConfig = true;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sha = (await git(folder, ["rev-parse", "HEAD"])).trim();
|
|
40
|
+
|
|
41
|
+
mkdirSync(jobDir, { recursive: true });
|
|
42
|
+
// The outbox is the container's only signal channel back to the worker (INT-OUTBOX-CONTRACT). It is
|
|
43
|
+
// mounted /outbox:rw for local jobs only; the host reads it after the run to enqueue chained children.
|
|
44
|
+
const outboxDir = join(jobDir, "outbox");
|
|
45
|
+
mkdirSync(outboxDir, { recursive: true });
|
|
46
|
+
// Instructions from HEAD, via the symlink-safe git materialiser, into /job/pi (mounted :ro).
|
|
47
|
+
const written = await materializePiDir({ gitDir: folder, sha, destDir: jobDir });
|
|
48
|
+
|
|
49
|
+
// The task the operator asked for. Plain data below the instructions.
|
|
50
|
+
writeFileSync(join(jobDir, "prompt.md"), String(task ?? ""), { mode: 0o444 });
|
|
51
|
+
|
|
52
|
+
// The trigger context, /job/event.json (INT-CONTAINER-JOB-INPUTS): one file per concern, 0o444 like
|
|
53
|
+
// the prompt, written unconditionally so every local run carries its origin. `folder` is the BASENAME
|
|
54
|
+
// only -- the full path embeds the operator's OS account name and /job is agent-readable, the same
|
|
55
|
+
// PII restraint run-history's `local:<basename>` target applies. The cron-only keys (trigger,
|
|
56
|
+
// scheduledFor, previousRunAt) appear only for a cron source, nulls preserved.
|
|
57
|
+
const eventBody = {
|
|
58
|
+
source: event.source,
|
|
59
|
+
...(event.trigger ? { trigger: event.trigger } : {}),
|
|
60
|
+
folder: basename(folder),
|
|
61
|
+
sha,
|
|
62
|
+
...(event.source === "cron" ? { scheduledFor: event.scheduledFor ?? null, previousRunAt: event.previousRunAt ?? null } : {}),
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(join(jobDir, "event.json"), JSON.stringify(eventBody, null, 2), { mode: 0o444 });
|
|
65
|
+
|
|
66
|
+
// The folder itself is /workspace (rw). No clone: local jobs edit in place.
|
|
67
|
+
return { workspace: folder, jobDir, outboxDir, sha, materialised: written };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function defaultGit(gitDir, args) {
|
|
71
|
+
const { stdout } = await exec("git", ["-c", "core.hooksPath=/dev/null", "--no-pager", "-C", gitDir, ...args], {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
74
|
+
});
|
|
75
|
+
return stdout;
|
|
76
|
+
}
|
package/src/prepare.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveJobImage } from "./image-preflight.mjs";
|
|
5
|
+
import { retainJobDir } from "./sandbox-store.mjs";
|
|
6
|
+
import { prepareGithubWorkspace } from "./prepare-github.mjs";
|
|
7
|
+
import { gitlabRemoteUrl } from "./gitlab-host.mjs";
|
|
8
|
+
import { forgejoRemoteUrl } from "./forgejo-host.mjs";
|
|
9
|
+
import { azureRemoteUrl } from "./azure-host.mjs";
|
|
10
|
+
import { buildGitLabPrompt } from "./gitlab-prompt.mjs";
|
|
11
|
+
import { buildForgejoPrompt } from "./forgejo-prompt.mjs";
|
|
12
|
+
import { buildAzurePrompt } from "./azure-prompt.mjs";
|
|
13
|
+
import { prepareLocalWorkspace } from "./prepare-local.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `prepareWorkspace` dispatcher the processor injects. Creates a per-job dir under `jobsDir`
|
|
17
|
+
* (holding the read-only /job inputs) and routes by job kind: local jobs go to `prepareLocalWorkspace`,
|
|
18
|
+
* forge-backed jobs to the preparer registered for their kind.
|
|
19
|
+
*
|
|
20
|
+
* The `flow` becomes a prompt hint; the actual skill is provided by the project's materialised
|
|
21
|
+
* .pi/skills.
|
|
22
|
+
*
|
|
23
|
+
* `forgeFor(job)` yields the `{ auth, host }` pair for that job's forge (start.mjs owns the map). The
|
|
24
|
+
* preparer is handed `host.resolveDefaultBranchSha` rather than the whole pair, so a preparer can only
|
|
25
|
+
* resolve a SHA -- it gets no minting capability and no comment surface it has no business holding.
|
|
26
|
+
*
|
|
27
|
+
* `findPreviousRun` (run-history's `makeFindPreviousRun`) feeds the cron event context below; the
|
|
28
|
+
* default returns null so an unwired dispatcher (tests, a bare construction) still writes a complete
|
|
29
|
+
* event with `previousRunAt: null`.
|
|
30
|
+
*/
|
|
31
|
+
export function makePrepareWorkspace({
|
|
32
|
+
jobsDir,
|
|
33
|
+
forgeFor,
|
|
34
|
+
// REQ-RESURRECTABLE-SANDBOX. The DEPLOYMENT default image, resolved per job against the trigger's own
|
|
35
|
+
// `run.image` through the SAME function the pre-spend preflight and run-container use -- so the tag that
|
|
36
|
+
// was checked, the tag that ran and the tag a sandbox later re-opens are one answer by construction
|
|
37
|
+
// rather than three call sites that happen to agree. Null leaves the stamp imageless, and
|
|
38
|
+
// `resolveSandbox` then refuses rather than guessing.
|
|
39
|
+
jobImage = null,
|
|
40
|
+
findPreviousRun = () => null,
|
|
41
|
+
// REQ-RESUMABLE-SESSION. The default returns null, so an unwired dispatcher -- tests, a bare
|
|
42
|
+
// construction -- prepares exactly what it always did: no /session mount, nothing on disk.
|
|
43
|
+
resolveSession = () => null,
|
|
44
|
+
prepareLocal = prepareLocalWorkspace,
|
|
45
|
+
// Keyed by `job.kind`, so a new forge is one entry rather than a new `if`. A kind with no entry falls
|
|
46
|
+
// through to the throw below, which is what makes an unrouted job loud instead of a silent no-op.
|
|
47
|
+
preparers = { github: prepareGithubWorkspace },
|
|
48
|
+
}) {
|
|
49
|
+
mkdirSync(jobsDir, { recursive: true });
|
|
50
|
+
return async function prepareWorkspace(job, token, { queueJobId, piVersion = null } = {}) {
|
|
51
|
+
const jobDir = mkdtempSync(join(jobsDir, "job-"));
|
|
52
|
+
// What `cleanup` needs to retain this run's directory, stamped here because this is the only place
|
|
53
|
+
// that holds all three at once. Applied to the RESULT rather than mutated in, so a preparer's
|
|
54
|
+
// `{ outcome: "policy" }` refusal -- which carries no jobDir -- is passed through untouched.
|
|
55
|
+
const sandbox = { jobId: queueJobId ?? null, kind: job.kind ?? null, image: resolveJobImage(job, jobImage) };
|
|
56
|
+
if (job.kind === "local") {
|
|
57
|
+
// Harness text above, operator DATA below: the fixed pointer line names /job/event.json so a
|
|
58
|
+
// flow can discover the trigger context (mirroring the github prompt, which names the same
|
|
59
|
+
// file); nothing in-container reads it otherwise. The pointer sits AFTER the flow hint and
|
|
60
|
+
// BEFORE the operator's task, which stays verbatim (CONST-ISSUE-TEXT-IS-DATA).
|
|
61
|
+
const pointer = "Context about this run -- its trigger and schedule -- is in /job/event.json.\n\n";
|
|
62
|
+
const task = job.flow
|
|
63
|
+
? `Use the "${job.flow}" skill for this task.\n\n${pointer}${job.task ?? ""}`
|
|
64
|
+
: `${pointer}${job.task ?? ""}`;
|
|
65
|
+
const event = localEventContext(job, queueJobId, findPreviousRun);
|
|
66
|
+
return stampSandbox(await prepareLocal({ folder: job.folder, task, jobDir, event }), sandbox);
|
|
67
|
+
}
|
|
68
|
+
const prepare = preparers[job.kind];
|
|
69
|
+
if (prepare) {
|
|
70
|
+
const host = forgeFor?.(job)?.host;
|
|
71
|
+
return stampSandbox(
|
|
72
|
+
await prepare(job, token, {
|
|
73
|
+
jobDir,
|
|
74
|
+
resolveDefaultBranchSha: host?.resolveDefaultBranchSha,
|
|
75
|
+
// The head ref a pull/merge-request job keys on comes from the FORGE API, never the webhook
|
|
76
|
+
// payload: an issue_comment on a PR carries no head at all, and a payload-supplied head repo
|
|
77
|
+
// is attacker-controlled data that must not decide which transcript a job is handed.
|
|
78
|
+
resolvePullRequestHead: host?.resolvePullRequestHead,
|
|
79
|
+
resolveSession,
|
|
80
|
+
piVersion,
|
|
81
|
+
}),
|
|
82
|
+
sandbox,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`unknown job kind: ${job.kind}`);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The trigger context a local job's `/job/event.json` carries (INT-CONTAINER-JOB-INPUTS). Source is
|
|
91
|
+
* derived from the job data alone: a chained child carries `parentJobId`/`chainDepth` (queue.mjs sets
|
|
92
|
+
* them only on chains), a scheduled job carries the cron-only `trigger` field (schedules.mjs), and
|
|
93
|
+
* everything else is the operator's own `pi-dispatch run` -- manual.
|
|
94
|
+
*
|
|
95
|
+
* For cron, the scheduled-for instant comes from BullMQ's deterministic `repeat:<id>:<millis>` jobId
|
|
96
|
+
* (DES-CRON-VIA-BULLMQ-SCHEDULER) -- the wiring injects it as `queueJobId`. When the id is missing or
|
|
97
|
+
* unparseable the lookup is SKIPPED: both `scheduledFor` and `previousRunAt` are null, never a guess.
|
|
98
|
+
*/
|
|
99
|
+
function localEventContext(job, queueJobId, findPreviousRun) {
|
|
100
|
+
if (job.parentJobId !== undefined || job.chainDepth !== undefined) return { source: "chain" };
|
|
101
|
+
if (job.trigger) {
|
|
102
|
+
const millis = scheduledForMillis(queueJobId);
|
|
103
|
+
return {
|
|
104
|
+
source: "cron",
|
|
105
|
+
trigger: job.trigger,
|
|
106
|
+
scheduledFor: millis === null ? null : new Date(millis).toISOString(),
|
|
107
|
+
previousRunAt: millis === null ? null : (findPreviousRun({ schedulerId: job.trigger.id, beforeMillis: millis }) ?? null),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { source: "manual" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse the millis out of a `repeat:<id>:<millis>` BullMQ scheduled jobId, or null. */
|
|
114
|
+
function scheduledForMillis(queueJobId) {
|
|
115
|
+
if (typeof queueJobId !== "string" || !queueJobId.startsWith("repeat:")) return null;
|
|
116
|
+
const tail = queueJobId.slice(queueJobId.lastIndexOf(":") + 1);
|
|
117
|
+
if (tail === "") return null;
|
|
118
|
+
const millis = Number(tail);
|
|
119
|
+
return Number.isFinite(millis) ? millis : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Attach the sandbox stamp to a successful prepare, and to nothing else.
|
|
124
|
+
*
|
|
125
|
+
* A preparer's determinate refusal (`{ outcome: "policy", reason: "sha-gone" }`) carries no `jobDir`, so
|
|
126
|
+
* it is returned verbatim: there is no directory to retain and the processor's policy branch reads the
|
|
127
|
+
* same object it always did.
|
|
128
|
+
*/
|
|
129
|
+
function stampSandbox(prepared, sandbox) {
|
|
130
|
+
if (!prepared?.jobDir) return prepared;
|
|
131
|
+
return { ...prepared, sandbox };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Remove a per-job dir after the run. The workspace (the operator's folder) is never touched here. */
|
|
135
|
+
export async function cleanup(prepared) {
|
|
136
|
+
if (prepared?.jobDir) await rm(prepared.jobDir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The teardown the worker injects: retain this run's directory for a bounded window instead of deleting
|
|
141
|
+
* it, so `pi-dispatch sandbox` can re-open it (REQ-RESURRECTABLE-SANDBOX).
|
|
142
|
+
*
|
|
143
|
+
* With the window at 0 -- the documented "off" -- this IS `cleanup`, by the same `rm` on the same path,
|
|
144
|
+
* so a deployment that does not want retention keeps today's behaviour exactly rather than a
|
|
145
|
+
* near-equivalent of it. `retainJobDir` owns the other branch entirely, including removing `jobDir` on
|
|
146
|
+
* every failure path, which is why there is no `rm` here to pair with it.
|
|
147
|
+
*
|
|
148
|
+
* NEVER THROWS -- the processor already swallows this in a `finally`, and a retention fault must not be
|
|
149
|
+
* the thing that turns a paid, completed run into a failure.
|
|
150
|
+
*/
|
|
151
|
+
export function makeCleanup({ sandboxDir, retentionHours = 0, log = () => {} } = {}) {
|
|
152
|
+
return async function cleanupWithRetention(prepared) {
|
|
153
|
+
if (!prepared?.jobDir) return;
|
|
154
|
+
if (!sandboxDir || retentionHours <= 0) {
|
|
155
|
+
await cleanup(prepared);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
retainJobDir(prepared, { sandboxDir, log });
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The per-forge preparer map `makePrepareWorkspace` dispatches on.
|
|
164
|
+
*
|
|
165
|
+
* Every forge shares `prepareGithubWorkspace`: the askpass helper, the hardening flags, the gone-SHA
|
|
166
|
+
* markers, the pinned detached checkout and the read-only event.json are facts about git and about this
|
|
167
|
+
* project, not about GitHub. Only two things differ, and both are injected -- where the clone comes from,
|
|
168
|
+
* and how the agent's envelope is phrased. A second copy of the clone path would be a second place to fix
|
|
169
|
+
* a clone bug, and the copy that did not get fixed would be the one nobody was looking at.
|
|
170
|
+
*
|
|
171
|
+
* Exported so the wiring is assertable: a job cloning from the wrong forge is a silent failure -- the URL
|
|
172
|
+
* simply would not exist, or worse, would.
|
|
173
|
+
*/
|
|
174
|
+
export function makeForgePreparers({ gitlabApiUrl = null, forgejoApiUrl = null, azureOrgUrl = null, prepareForge = prepareGithubWorkspace } = {}) {
|
|
175
|
+
return {
|
|
176
|
+
github: prepareForge,
|
|
177
|
+
gitlab: (job, token, opts) =>
|
|
178
|
+
prepareForge(job, token, {
|
|
179
|
+
...opts,
|
|
180
|
+
remoteUrlFor: (j) => gitlabRemoteUrl(gitlabApiUrl, j.repo),
|
|
181
|
+
buildPrompt: buildGitLabPrompt,
|
|
182
|
+
}),
|
|
183
|
+
forgejo: (job, token, opts) =>
|
|
184
|
+
prepareForge(job, token, {
|
|
185
|
+
...opts,
|
|
186
|
+
remoteUrlFor: (j) => forgejoRemoteUrl(forgejoApiUrl, j.repo),
|
|
187
|
+
buildPrompt: buildForgejoPrompt,
|
|
188
|
+
}),
|
|
189
|
+
azure: (job, token, opts) =>
|
|
190
|
+
prepareForge(job, token, {
|
|
191
|
+
...opts,
|
|
192
|
+
// Azure's clone URL is `<org>/<project>/_git/<repo>` -- built from the job's structured scope,
|
|
193
|
+
// not from its `repo` label, because that label is `project/repo` and reassembling a URL from
|
|
194
|
+
// a display string is how the wrong repository gets cloned.
|
|
195
|
+
remoteUrlFor: (j) => azureRemoteUrl(azureOrgUrl, j),
|
|
196
|
+
buildPrompt: buildAzurePrompt,
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
package/src/pricing.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing façade over pi-ai (issue #53). pi-dispatch holds NO pricing table and computes NOTHING of
|
|
3
|
+
* its own -- pricing is pi-ai's (`calculateCost` over its per-provider model tables). The admin
|
|
4
|
+
* extension needs to re-price recorded token profiles (what-if: "this run's tokens at that model's
|
|
5
|
+
* rates") and to enumerate priced models, and it must NOT grow its own pi-ai dependency -- that would
|
|
6
|
+
* be a fourth exact pin and a second drift axis between the recorded truth and the screen. So the
|
|
7
|
+
* admin imports this worker export instead: the same anti-drift idiom as `budget`'s dayKey and
|
|
8
|
+
* `subscriptions`' parser -- one side owns the artifact, the other imports it, and the two cannot
|
|
9
|
+
* drift.
|
|
10
|
+
*
|
|
11
|
+
* Stream-time cost on the usage-ledger record stays the METERED truth; this façade prices
|
|
12
|
+
* COUNTERFACTUALS only (same posture as DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY). Nothing here
|
|
13
|
+
* touches job execution, routing, or the record path.
|
|
14
|
+
*
|
|
15
|
+
* Import discipline: enumeration comes from "@earendil-works/pi-ai/providers/all" and the arithmetic
|
|
16
|
+
* from the root "@earendil-works/pi-ai" -- both declared side-effect-free in pi-ai's package.json
|
|
17
|
+
* (`sideEffects` names only compat/images registration modules). NEVER import
|
|
18
|
+
* "@earendil-works/pi-ai/compat" here: it registers providers at module scope, and a pricing lookup
|
|
19
|
+
* must not mutate global registries as a side effect of being asked a question.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
import { calculateCost } from "@earendil-works/pi-ai";
|
|
27
|
+
import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every builtin pi-ai model, flattened to `{ provider, id, cost }`. `cost` is pi-ai's ModelCost
|
|
31
|
+
* object passed BY REFERENCE -- it is pi-ai's data, callers treat it read-only. Rates are USD per
|
|
32
|
+
* 1M tokens; an all-zero table is CORRECT data for subscription-backed providers (kimi-coding,
|
|
33
|
+
* zai-coding-cn), not missing data -- `isZeroRated` is how callers tell the two apart.
|
|
34
|
+
*/
|
|
35
|
+
export function listPricedModels() {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const provider of getBuiltinProviders()) {
|
|
38
|
+
for (const model of getBuiltinModels(provider)) {
|
|
39
|
+
out.push({ provider, id: model.id, cost: model.cost });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Look up one builtin model; the pi-ai Model or null. Never throws on garbage: non-string args are
|
|
47
|
+
* refused, and because pi-ai's generated catalog is a plain object literal, keys like "__proto__"
|
|
48
|
+
* or "constructor" would resolve THROUGH the prototype chain to real objects -- so the provider must
|
|
49
|
+
* be an own catalog key and the hit must actually be a model (carrying its own id and a cost table).
|
|
50
|
+
*/
|
|
51
|
+
export function getPricedModel(provider, id) {
|
|
52
|
+
if (typeof provider !== "string" || typeof id !== "string") return null;
|
|
53
|
+
if (!getBuiltinProviders().includes(provider)) return null;
|
|
54
|
+
const model = getBuiltinModel(provider, id);
|
|
55
|
+
if (model === null || model === undefined || typeof model !== "object") return null;
|
|
56
|
+
if (model.id !== id || model.cost === null || typeof model.cost !== "object") return null;
|
|
57
|
+
return model;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when all four base rates are zero -- the signature of a subscription-backed provider, whose
|
|
62
|
+
* runs meter at $0 because the plan is prepaid (see subscriptions.mjs for where the real price
|
|
63
|
+
* lives). Tiers are deliberately ignored: a zero-rate provider ships no tiers, and a priced provider
|
|
64
|
+
* with a zero base rate somewhere does not become "free" by it. Null/malformed input is false --
|
|
65
|
+
* "not zero-rated" is the safe answer for a thing that is not a model.
|
|
66
|
+
*/
|
|
67
|
+
export function isZeroRated(model) {
|
|
68
|
+
const cost = model?.cost;
|
|
69
|
+
if (cost === null || cost === undefined || typeof cost !== "object") return false;
|
|
70
|
+
return cost.input === 0 && cost.output === 0 && cost.cacheRead === 0 && cost.cacheWrite === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Default reader for piAiVersion; injectable so tests never depend on disk layout. */
|
|
74
|
+
function readTextFromDisk(path) {
|
|
75
|
+
return readFileSync(path, "utf8");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the pinned pi-ai's version from its package.json on disk. pi-ai's exports map has NO
|
|
80
|
+
* "./package.json" entry, so the file cannot be imported or require.resolved directly -- and the map
|
|
81
|
+
* carries only the "import" condition, so createRequire().resolve() on the bare specifier throws
|
|
82
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED too. import.meta.resolve is the resolution that works: it yields the
|
|
83
|
+
* ESM entry (dist/index.js), and from there we walk parent directories until a package.json names
|
|
84
|
+
* the package. Bounded walk, every step forgiving: any read/parse/mismatch just keeps climbing, and
|
|
85
|
+
* any failure overall is null, never a throw.
|
|
86
|
+
*/
|
|
87
|
+
function readPiAiVersion(readText) {
|
|
88
|
+
try {
|
|
89
|
+
let dir = dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
|
|
90
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(readText(join(dir, "package.json")));
|
|
93
|
+
if (parsed !== null && parsed.name === "@earendil-works/pi-ai") {
|
|
94
|
+
const match = /^(\d+\.\d+\.\d+)/.exec(String(parsed.version ?? ""));
|
|
95
|
+
return match === null ? null : match[1];
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// dist/ has no package.json (ENOENT), or the injected reader refused -- keep climbing.
|
|
99
|
+
}
|
|
100
|
+
const parent = dirname(dir);
|
|
101
|
+
if (parent === dir) break;
|
|
102
|
+
dir = parent;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// import.meta.resolve failed: pi-ai is not installed where we run. null says so quietly.
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let defaultVersionCache;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The pinned pi-ai's "<major.minor.patch>", or null. Lazy and cached (a null miss is cached too --
|
|
114
|
+
* the answer will not change within a process), and it never throws: the version tags counterfactual
|
|
115
|
+
* prices as `ratesVersion`, and a cosmetic label must never take the pricing path down. An injected
|
|
116
|
+
* `readText` bypasses the cache and reads fresh -- that path exists for tests.
|
|
117
|
+
*/
|
|
118
|
+
export function piAiVersion({ readText } = {}) {
|
|
119
|
+
if (readText !== undefined) return readPiAiVersion(readText);
|
|
120
|
+
if (defaultVersionCache === undefined) defaultVersionCache = readPiAiVersion(readTextFromDisk);
|
|
121
|
+
return defaultVersionCache;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A token count from an untrusted profile: finite and positive, or 0. */
|
|
125
|
+
function tokenCount(value) {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Price a recorded token profile at another model's rates: `{ usd, ratesVersion }`, or null when the
|
|
131
|
+
* target is not a builtin model. `quad` is `{ input, output, cacheRead, cacheWrite, cacheWrite1h }`;
|
|
132
|
+
* non-finite/negative counts are treated as 0, and the caller's object is never touched.
|
|
133
|
+
*
|
|
134
|
+
* The one judgment this façade makes is the 1h cache split. pi-ai's `calculateCost` applies the
|
|
135
|
+
* 2x-base-input premium for ANY model when `cacheWrite1h > 0`, but the premium is an ANTHROPIC
|
|
136
|
+
* billing rule -- only Anthropic ever reports the field. Carrying a source profile's 1h split onto a
|
|
137
|
+
* target that never bills it would invent cost out of thin air, so the split is forwarded only when
|
|
138
|
+
* the TARGET is anthropic; everywhere else every write is priced short. (And a profile claiming more
|
|
139
|
+
* 1h writes than writes is malformed: clamped, so pi-ai's short-write term cannot go negative.)
|
|
140
|
+
*/
|
|
141
|
+
export function reprice(quad, target, { readText } = {}) {
|
|
142
|
+
const model = getPricedModel(target?.provider, target?.id);
|
|
143
|
+
if (model === null) return null;
|
|
144
|
+
|
|
145
|
+
const input = tokenCount(quad?.input);
|
|
146
|
+
const output = tokenCount(quad?.output);
|
|
147
|
+
const cacheRead = tokenCount(quad?.cacheRead);
|
|
148
|
+
const cacheWrite = tokenCount(quad?.cacheWrite);
|
|
149
|
+
const cacheWrite1h = model.provider === "anthropic"
|
|
150
|
+
? Math.min(tokenCount(quad?.cacheWrite1h), cacheWrite)
|
|
151
|
+
: 0;
|
|
152
|
+
|
|
153
|
+
// A FRESH Usage every call, never a shared or caller-owned object: calculateCost MUTATES its
|
|
154
|
+
// argument in place (usage.cost.* is assigned, not returned fresh) and REQUIRES the cost skeleton
|
|
155
|
+
// to already exist -- handing it a shared object would leak one caller's price into the next, and
|
|
156
|
+
// omitting the skeleton is a TypeError (both pinned in pricing.test.mjs).
|
|
157
|
+
const usage = {
|
|
158
|
+
input,
|
|
159
|
+
output,
|
|
160
|
+
cacheRead,
|
|
161
|
+
cacheWrite,
|
|
162
|
+
cacheWrite1h,
|
|
163
|
+
totalTokens: input + output + cacheRead + cacheWrite,
|
|
164
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
165
|
+
};
|
|
166
|
+
calculateCost(model, usage);
|
|
167
|
+
return { usd: usage.cost.total, ratesVersion: piAiVersion({ readText }) };
|
|
168
|
+
}
|