mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): add Nix flake (#9137)
Add a Nix flake that builds Pi from the checkout on aarch64/x86_64 Linux and macOS. The build is offline: provider model data is hydrated from a pi.dev model catalog revision pinned in nix/model-catalog.json, and the runtime dependency tree comes from packages/coding-agent/install-lock with internal packages built from source. The Nix workflow keeps the catalog pin current on main, builds release tags on all systems, and fast-forwards the stable branch to each release that builds. The release script refreshes the pin before tagging.
This commit is contained in:
parent
4665fafb4d
commit
c71a496ff9
18 changed files with 997 additions and 19 deletions
|
|
@ -9,6 +9,7 @@
|
|||
* Steps:
|
||||
* 1. Check for uncommitted changes
|
||||
* 2. Verify every public workspace package is registered on npm
|
||||
* and refresh the Nix model catalog pin if it is stale
|
||||
* 3. Bump version via npm run version:xxx or set an explicit version
|
||||
* 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date
|
||||
* 5. Regenerate release artifacts
|
||||
|
|
@ -224,6 +225,13 @@ console.log(" Working directory clean\n");
|
|||
// 2. Verify npm package registration before modifying the worktree.
|
||||
assertPackagesAreRegisteredWithNpm();
|
||||
|
||||
// Release tags are immutable, so the tagged Nix pin must already build the
|
||||
// release. Refresh it before the version bump, while pi.dev still knows the
|
||||
// current version, so it lands in the release commit.
|
||||
console.log("Refreshing the Nix model catalog pin if stale...");
|
||||
run("node scripts/update-model-catalog-pin.mjs --if-stale");
|
||||
console.log();
|
||||
|
||||
// 3. Bump or set version
|
||||
const version = bumpOrSetVersion(RELEASE_TARGET);
|
||||
console.log(` New version: ${version}\n`);
|
||||
|
|
|
|||
115
scripts/update-model-catalog-pin.mjs
Normal file
115
scripts/update-model-catalog-pin.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { hydrateModelCatalog } from "../packages/ai/scripts/hydrate-model-catalog.ts";
|
||||
import { readModelDataProviderIds } from "../packages/ai/scripts/model-data.ts";
|
||||
|
||||
// Request the typed catalog; its bytes are what a revision hashes.
|
||||
const MODEL_TYPES = "types=chat,image,classifier";
|
||||
const REVISION_RE = /^sha256-[0-9a-f]{64}$/;
|
||||
|
||||
function sha256(bytes) {
|
||||
return `sha256-${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
}
|
||||
|
||||
async function fetchRevision(revision) {
|
||||
const response = await fetch(`https://pi.dev/api/models/revisions/${revision}?${MODEL_TYPES}`);
|
||||
if (!response.ok) throw new Error(`Catalog revision ${revision} is unavailable: HTTP ${response.status}`);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (sha256(bytes) !== revision) throw new Error(`Catalog revision ${revision} does not match its content`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function fetchLiveCatalog(root) {
|
||||
const { version } = JSON.parse(readFileSync(join(root, "packages/coding-agent/package.json"), "utf8"));
|
||||
const response = await fetch(`https://pi.dev/api/models?pi-version=${encodeURIComponent(version)}&${MODEL_TYPES}`);
|
||||
if (!response.ok) throw new Error(`Catalog discovery failed: HTTP ${response.status}`);
|
||||
const revision = response.headers.get("x-pi-model-catalog-revision");
|
||||
if (typeof revision !== "string" || !REVISION_RE.test(revision)) {
|
||||
throw new Error("Catalog discovery returned an invalid or missing revision");
|
||||
}
|
||||
if (sha256(Buffer.from(await response.arrayBuffer())) !== revision) {
|
||||
throw new Error("Catalog discovery body does not match its revision");
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `provider/type` model groups the catalog provides for this
|
||||
* checkout's providers. The generated `*.models.ts` types are derived from the
|
||||
* data, so code that uses a group (e.g. OPENCODE_CLASSIFIER_MODELS) only
|
||||
* type-checks when the catalog has models of that type for the provider.
|
||||
*/
|
||||
function modelTypeGroups(root, bytes) {
|
||||
const catalog = JSON.parse(bytes.toString("utf8"));
|
||||
const groups = new Set();
|
||||
for (const provider of readModelDataProviderIds(join(root, "packages/ai"))) {
|
||||
for (const model of catalog[provider] ?? []) groups.add(`${provider}/${model.type}`);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Return why the catalog cannot hydrate this checkout, or undefined if it can. */
|
||||
function hydrationProblem(root, bytes) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "pi-model-catalog-pin-"));
|
||||
try {
|
||||
const catalogPath = join(directory, "models.all.json");
|
||||
writeFileSync(catalogPath, bytes);
|
||||
hydrateModelCatalog(join(root, "packages/ai"), catalogPath, { validateOnly: true });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the live model catalog for Nix builds. With `ifStale`, keep the current
|
||||
* pin while it still hydrates this checkout and has every model type the live
|
||||
* catalog has for the checkout's providers.
|
||||
*/
|
||||
export async function updateModelCatalogPin(root, { ifStale = false } = {}) {
|
||||
const pinPath = join(root, "nix/model-catalog.json");
|
||||
const current = JSON.parse(readFileSync(pinPath, "utf8")).revision;
|
||||
let currentBytes;
|
||||
if (ifStale) {
|
||||
if (typeof current !== "string" || !REVISION_RE.test(current)) throw new Error(`Invalid pin in ${pinPath}`);
|
||||
currentBytes = await fetchRevision(current);
|
||||
const problem = hydrationProblem(root, currentBytes);
|
||||
if (problem) {
|
||||
console.error(`Pinned model catalog is stale: ${problem}`);
|
||||
currentBytes = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const revision = await fetchLiveCatalog(root);
|
||||
// Do not record a pin until its immutable URL serves a catalog this checkout can build with.
|
||||
const bytes = await fetchRevision(revision);
|
||||
if (currentBytes) {
|
||||
// The live catalog is generated from main. Main's code may use a model type
|
||||
// the pin lacks, e.g. after a provider gained classifier models.
|
||||
const pinned = modelTypeGroups(root, currentBytes);
|
||||
const missing = [...modelTypeGroups(root, bytes)].filter((group) => !pinned.has(group));
|
||||
if (missing.length === 0) return { revision: current, updated: false };
|
||||
console.error(`Pinned model catalog is stale: missing model types ${missing.sort().join(", ")}`);
|
||||
}
|
||||
const problem = hydrationProblem(root, bytes);
|
||||
if (problem) throw new Error(`Live model catalog ${revision} cannot hydrate this checkout: ${problem}`);
|
||||
writeFileSync(pinPath, `${JSON.stringify({ revision }, null, 2)}\n`);
|
||||
return { revision, updated: revision !== current };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== "--if-stale")) {
|
||||
throw new Error("Usage: node scripts/update-model-catalog-pin.mjs [--if-stale]");
|
||||
}
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const { revision, updated } = await updateModelCatalogPin(root, { ifStale: args[0] === "--if-stale" });
|
||||
console.log(`${updated ? "Pinned" : "Kept"} model catalog ${revision}`);
|
||||
}
|
||||
149
scripts/update-model-catalog-pin.test.mjs
Normal file
149
scripts/update-model-catalog-pin.test.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, mock, test } from "node:test";
|
||||
import { updateModelCatalogPin } from "./update-model-catalog-pin.mjs";
|
||||
|
||||
const model = {
|
||||
type: "chat",
|
||||
id: "model-a",
|
||||
name: "Model A",
|
||||
api: "openai-completions",
|
||||
provider: "test-provider",
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
const catalog = (models) => `${JSON.stringify({ "test-provider": models })}\n`;
|
||||
const revisionOf = (body) => `sha256-${createHash("sha256").update(body).digest("hex")}`;
|
||||
const pinFile = (revision) => `${JSON.stringify({ revision }, null, 2)}\n`;
|
||||
|
||||
const liveBody = catalog([model, { ...model, id: "model-b" }]);
|
||||
const liveRevision = revisionOf(liveBody);
|
||||
const currentBody = catalog([model]);
|
||||
const currentRevision = revisionOf(currentBody);
|
||||
// Hydration fails when a checkout provider is missing, e.g. after main adds one.
|
||||
const staleBody = `${JSON.stringify({ "other-provider": [model] })}\n`;
|
||||
const staleRevision = revisionOf(staleBody);
|
||||
// Hydrates, but lacks a model type the live catalog (generated from main) has.
|
||||
const classifier = { ...model, type: "classifier", id: "classifier-a", name: "Classifier A" };
|
||||
delete classifier.reasoning;
|
||||
delete classifier.maxTokens;
|
||||
const classifierBody = catalog([model, classifier]);
|
||||
const classifierRevision = revisionOf(classifierBody);
|
||||
const bodies = new Map([
|
||||
[liveRevision, liveBody],
|
||||
[currentRevision, currentBody],
|
||||
[staleRevision, staleBody],
|
||||
[classifierRevision, classifierBody],
|
||||
]);
|
||||
const typed = "types=chat,image,classifier";
|
||||
|
||||
let root;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "pi-catalog-pin-"));
|
||||
mkdirSync(join(root, "packages/coding-agent"), { recursive: true });
|
||||
mkdirSync(join(root, "packages/ai/src/providers"), { recursive: true });
|
||||
mkdirSync(join(root, "nix"));
|
||||
writeFileSync(join(root, "packages/coding-agent/package.json"), JSON.stringify({ version: "0.85.1" }));
|
||||
writeFileSync(
|
||||
join(root, "packages/ai/src/models.generated.ts"),
|
||||
'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n',
|
||||
);
|
||||
writeFileSync(join(root, "packages/ai/src/providers/test-provider.models.ts"), "");
|
||||
writeFileSync(join(root, "nix/model-catalog.json"), pinFile(currentRevision));
|
||||
});
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function readPin() {
|
||||
return readFileSync(join(root, "nix/model-catalog.json"), "utf8");
|
||||
}
|
||||
|
||||
function mockPiDev({ live = liveRevision, failure } = {}) {
|
||||
const requests = [];
|
||||
mock.method(globalThis, "fetch", async (url) => {
|
||||
requests.push(url);
|
||||
const revision = /\/revisions\/(sha256-[0-9a-f]+)\?/.exec(url)?.[1];
|
||||
if (revision) {
|
||||
if (failure === "revision-http") return new Response(null, { status: 503 });
|
||||
return new Response(failure === "revision-hash" ? "wrong" : bodies.get(revision));
|
||||
}
|
||||
if (failure === "discovery-http") return new Response(null, { status: 503 });
|
||||
return new Response(failure === "discovery-hash" ? "wrong" : bodies.get(live), {
|
||||
headers: { "x-pi-model-catalog-revision": failure === "discovery-revision" ? "latest" : live },
|
||||
});
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
test("pins the live typed catalog after verifying its immutable URL", async () => {
|
||||
const requests = mockPiDev();
|
||||
assert.deepEqual(await updateModelCatalogPin(root), { revision: liveRevision, updated: true });
|
||||
assert.deepEqual(requests, [
|
||||
`https://pi.dev/api/models?pi-version=0.85.1&${typed}`,
|
||||
`https://pi.dev/api/models/revisions/${liveRevision}?${typed}`,
|
||||
]);
|
||||
assert.equal(readPin(), pinFile(liveRevision));
|
||||
});
|
||||
|
||||
test("keeps a pin that still hydrates the checkout and has the live model types", async () => {
|
||||
const requests = mockPiDev();
|
||||
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), {
|
||||
revision: currentRevision,
|
||||
updated: false,
|
||||
});
|
||||
assert.deepEqual(requests, [
|
||||
`https://pi.dev/api/models/revisions/${currentRevision}?${typed}`,
|
||||
`https://pi.dev/api/models?pi-version=0.85.1&${typed}`,
|
||||
`https://pi.dev/api/models/revisions/${liveRevision}?${typed}`,
|
||||
]);
|
||||
assert.equal(readPin(), pinFile(currentRevision));
|
||||
});
|
||||
|
||||
test("replaces a pin that lacks a model type of the live catalog", async (t) => {
|
||||
const errors = [];
|
||||
t.mock.method(console, "error", (message) => errors.push(message));
|
||||
mockPiDev({ live: classifierRevision });
|
||||
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), {
|
||||
revision: classifierRevision,
|
||||
updated: true,
|
||||
});
|
||||
assert.equal(readPin(), pinFile(classifierRevision));
|
||||
assert.deepEqual(errors, ["Pinned model catalog is stale: missing model types test-provider/classifier"]);
|
||||
});
|
||||
|
||||
test("replaces a pin that no longer hydrates the checkout", async (t) => {
|
||||
t.mock.method(console, "error", () => {});
|
||||
writeFileSync(join(root, "nix/model-catalog.json"), pinFile(staleRevision));
|
||||
mockPiDev();
|
||||
assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), { revision: liveRevision, updated: true });
|
||||
assert.equal(readPin(), pinFile(liveRevision));
|
||||
});
|
||||
|
||||
test("refuses to pin a live catalog that cannot hydrate the checkout", async () => {
|
||||
mockPiDev({ live: staleRevision });
|
||||
await assert.rejects(updateModelCatalogPin(root), /cannot hydrate this checkout/);
|
||||
assert.equal(readPin(), pinFile(currentRevision));
|
||||
});
|
||||
|
||||
for (const failure of ["discovery-http", "discovery-revision", "discovery-hash", "revision-http", "revision-hash"]) {
|
||||
test(`keeps the old pin on ${failure} failure`, async () => {
|
||||
mockPiDev({ failure });
|
||||
await assert.rejects(updateModelCatalogPin(root));
|
||||
assert.equal(readPin(), pinFile(currentRevision));
|
||||
});
|
||||
}
|
||||
|
||||
test("does not treat an unreachable pinned revision as stale", async () => {
|
||||
mockPiDev({ failure: "revision-http" });
|
||||
await assert.rejects(updateModelCatalogPin(root, { ifStale: true }), /unavailable/);
|
||||
assert.equal(readPin(), pinFile(currentRevision));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue