Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .agents/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ Two standing sections outlive any single batch and must be read on every review:

## Review batches

## 2026-09-05 — targeted Astra model classification

The maintainer directed completing the Astra integration after review identified
upstream `bc03c3640d6d3bb44e5fb477bfd78d7484cd0e00` / `#9762`. Adopted on
`upstream/2026-09-05-astra`: add `gpt-6-astra` to the current Codex models.
Upstream's `updatedAt` field is omitted because Pylon's schema does not accept it.
The separate default-selection proposal `#9957` closed unmerged and is not part
of this classification change.

The end-to-end check found that Pylon's publisher had failed since September 2:
it still rejected the `providers` field adopted with `#9084`. Publication now
uses the server decoder and emits a complete `model-catalog.json` plus the
original classification-only `model-manifest.json` for older strict readers.
Current servers fetch the catalog; both feeds receive the Astra classification.

This is a filtered review at upstream head
`f8b4c464b4760d73e0ece7e68011c738803d8b69`; `reviewed-through` remains unchanged.
DEF-8 is due (Expo 57 is merged and its September 5 floor is reached); DEF-7
has not reached its November 1 floor. WATCH-1's upstream issue `#5760` is closed,
Pylon `#178` remains open, and upstream `#8097` remains open. WATCH-2 and WATCH-3
remain closed-unmerged upstream efforts with November 1 revisit floors. No
register or owner-issue decisions are implied by this targeted Astra change.

## 2026-09-05 — first catch-up batch and watch recheck (partial)

The maintainer approved A1–A4 after reviewing the bounded upstream head
Expand Down
104 changes: 27 additions & 77 deletions .github/workflows/publish-model-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
paths:
- apps/server/src/provider/model-manifest.json
- .github/workflows/publish-model-manifest.yml
- apps/server/scripts/prepare-model-manifests.ts
- apps/server/src/provider/modelManifestPublication.ts

permissions:
contents: read
Expand All @@ -19,8 +21,28 @@ jobs:
publish:
name: Publish public manifest
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
steps:
- name: Checkout approved source revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Setup Vite+
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: true
run-install: true

- name: Validate and prepare public manifests
run: node apps/server/scripts/prepare-model-manifests.ts /tmp/pylon-model-manifests

- name: Check source revision is current
id: source_revision
shell: bash
Expand Down Expand Up @@ -76,79 +98,6 @@ jobs:
exit 1
fi

- name: Fetch exact approved Pylon manifest
if: steps.source_revision.outputs.should_publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
SOURCE_REPOSITORY: ${{ github.repository }}
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
curl \
--proto '=https' \
--tlsv1.2 \
--fail \
--silent \
--show-error \
--location \
--connect-timeout 10 \
--max-time 30 \
--max-filesize 32768 \
--retry 2 \
--retry-all-errors \
--retry-delay 1 \
--header "Accept: application/vnd.github.raw+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--output /tmp/pylon-source-model-manifest.json \
"${GITHUB_API_URL}/repos/${SOURCE_REPOSITORY}/contents/apps/server/src/provider/model-manifest.json?ref=${SOURCE_SHA}"

- name: Validate source manifest
if: steps.source_revision.outputs.should_publish == 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require("node:fs");
const sourcePath = "/tmp/pylon-source-model-manifest.json";
const publicPath = "/tmp/pylon-model-manifest.json";
const maxBytes = 32 * 1024;
const maxModelsPerProvider = 256;
const maxSlugLength = 256;
const supportedDrivers = new Set(["codex", "claudeAgent"]);
const source = fs.readFileSync(sourcePath);
if (source.byteLength > maxBytes) {
throw new Error("Model manifest exceeds its public byte limit");
}
const manifest = JSON.parse(source.toString("utf8"));
const topLevelFields = Object.keys(manifest).sort();
if (JSON.stringify(topLevelFields) !== JSON.stringify(["currentModels", "version"])) {
throw new Error("Model manifest has unsupported top-level fields");
}
if (manifest.version !== 1 || typeof manifest.currentModels !== "object" || manifest.currentModels === null || Array.isArray(manifest.currentModels)) {
throw new Error("Unsupported model manifest schema");
}
const currentModels = Object.entries(manifest.currentModels);
if (currentModels.length > supportedDrivers.size || currentModels.some(([driver]) => !supportedDrivers.has(driver))) {
throw new Error("Model manifest has an unsupported driver kind");
}
for (const [driver, models] of currentModels) {
if (!Array.isArray(models) || models.length > maxModelsPerProvider || new Set(models).size !== models.length || models.some((model) => typeof model !== "string" || !model || model.length > maxSlugLength)) {
throw new Error(`Invalid model list for ${driver}`);
}
}
const publicManifest = {
version: manifest.version,
currentModels: Object.fromEntries(currentModels),
};
const serialized = `${JSON.stringify(publicManifest, null, 2)}\n`;
if (Buffer.byteLength(serialized) > maxBytes) {
throw new Error("Serialized public model manifest exceeds its byte limit");
}
fs.writeFileSync(publicPath, serialized);
NODE

- name: Checkout public releases repository
if: steps.source_revision.outputs.should_publish == 'true'
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
Expand All @@ -164,14 +113,15 @@ jobs:
shell: bash
run: |
set -euo pipefail
cp /tmp/pylon-model-manifest.json public/model-manifest.json
cp /tmp/pylon-model-manifests/model-manifest.json public/model-manifest.json
cp /tmp/pylon-model-manifests/model-catalog.json public/model-catalog.json
cd public
if [[ -z "$(git status --porcelain -- model-manifest.json)" ]]; then
if [[ -z "$(git status --porcelain -- model-manifest.json model-catalog.json)" ]]; then
echo "The public manifest is already current."
exit 0
fi
git config user.name "pylon-release-bot"
git config user.email "release-bot@users.noreply.github.com"
git add model-manifest.json
git add model-manifest.json model-catalog.json
git commit -m "chore: update model manifest"
git push origin HEAD:main
30 changes: 30 additions & 0 deletions apps/server/scripts/prepare-model-manifests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { decodeManifestJson } from "../src/provider/ModelManifest.ts";
import { serializeModelManifestPublication } from "../src/provider/modelManifestPublication.ts";

const destination = process.argv[2];
if (!destination) {
throw new Error("Usage: prepare-model-manifests.ts <output-directory>");
}

Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sourcePath = yield* path.fromFileUrl(
new URL("../src/provider/model-manifest.json", import.meta.url),
);
const source = yield* fileSystem.readFileString(sourcePath);
// Reuse runtime validation, including driver bounds and provider adapter metadata,
// before either public file is written.
const manifest = yield* decodeManifestJson(source);
const files = serializeModelManifestPublication(manifest);
yield* fileSystem.makeDirectory(destination, { recursive: true });
for (const { name, contents } of files) {
yield* fileSystem.writeFileString(path.join(destination, name), contents);
}
}).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
2 changes: 1 addition & 1 deletion apps/server/src/provider/ModelManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe("ModelManifest service", () => {
yield* service.refresh;
assert.strictEqual(
fetchedUrl,
"https://raw.githubusercontent.com/pylon-code/pylon-releases/main/model-manifest.json",
"https://raw.githubusercontent.com/pylon-code/pylon-releases/main/model-catalog.json",
);
}).pipe(
Effect.scoped,
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/provider/ModelManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import bundledManifestJson from "./model-manifest.json" with { type: "json" };
import type { ServerProviderDraft } from "./providerSnapshot.ts";

const MODEL_MANIFEST_URL =
"https://raw.githubusercontent.com/pylon-code/pylon-releases/main/model-manifest.json";
"https://raw.githubusercontent.com/pylon-code/pylon-releases/main/model-catalog.json";

/** How long a fetched manifest stays fresh before the next probe re-fetches. */
const MANIFEST_TTL_MS = 60 * 60 * 1000;
Expand All @@ -48,7 +48,7 @@ const MANIFEST_TTL_MS = 60 * 60 * 1000;
const MANIFEST_RETRY_MS = 5 * 60 * 1000;

const FETCH_TIMEOUT_MS = 10_000;
const MODEL_MANIFEST_MAX_BYTES = 32 * 1024;
export const MODEL_MANIFEST_MAX_BYTES = 32 * 1024;
const MODEL_MANIFEST_MAX_PROVIDERS = 8;
const MODEL_MANIFEST_MAX_MODELS_PER_PROVIDER = 256;
const MODEL_MANIFEST_MAX_SLUG_LENGTH = 256;
Expand Down Expand Up @@ -148,7 +148,7 @@ const decodeManifestJsonSchema = Schema.decodeUnknownEffect(
ModelManifestSchema as unknown as Schema.Codec<typeof ModelManifestSchema.Type>,
),
);
const decodeManifestJson = (input: string) =>
export const decodeManifestJson = (input: string) =>
decodeManifestJsonSchema(input).pipe(Effect.flatMap(validateManifestDrivers));

export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/model-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"version": 1,
"currentModels": {
"codex": [
"gpt-6-astra",
"gpt-5.6-luna",
"gpt-5.6-terra",
"gpt-5.6-sol",
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/provider/modelManifestPublication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { assert, describe, it } from "@effect/vitest";
import * as Schema from "effect/Schema";

import { type ModelManifestData, MODEL_MANIFEST_MAX_BYTES } from "./ModelManifest.ts";
import { serializeModelManifestPublication } from "./modelManifestPublication.ts";

const manifest: ModelManifestData = {
version: 1,
currentModels: { codex: ["current-model"] },
providers: {
claudeAgent: {
profiles: {},
models: [{ slug: "catalog-model", name: "Catalog Model", status: "current" }],
},
},
};

describe("model manifest publication", () => {
it("keeps the classification feed compatible with strict pre-catalog readers", () => {
const files = serializeModelManifestPublication(manifest);
const classification = JSON.parse(
files.find((file) => file.name === "model-manifest.json")!.contents,
);
const legacySchema = Schema.Struct({
version: Schema.Literal(1),
currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
}).annotate({ parseOptions: { onExcessProperty: "error" } });

assert.deepStrictEqual(Schema.decodeUnknownSync(legacySchema)(classification), {
version: 1,
currentModels: manifest.currentModels,
});
assert.deepStrictEqual(
JSON.parse(files.find((file) => file.name === "model-catalog.json")!.contents),
manifest,
);
});

it("rejects oversized catalog metadata before producing any files", () => {
const oversized = {
...manifest,
providers: {
claudeAgent: {
profiles: {},
models: [
{
slug: "large-model",
name: "x".repeat(MODEL_MANIFEST_MAX_BYTES),
status: "current" as const,
},
],
},
},
};
assert.throws(() => serializeModelManifestPublication(oversized), /model-catalog.json exceeds/);
});
});
24 changes: 24 additions & 0 deletions apps/server/src/provider/modelManifestPublication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MODEL_MANIFEST_MAX_BYTES, type ModelManifestData } from "./ModelManifest.ts";

/** Publish the validated catalog alongside the classification feed older servers accept. */
export function serializeModelManifestPublication(manifest: ModelManifestData) {
const classification = {
version: manifest.version,
currentModels: manifest.currentModels,
};
const files = {
"model-manifest.json": classification,
"model-catalog.json": {
...classification,
...(manifest.providers ? { providers: manifest.providers } : {}),
},
};

return Object.entries(files).map(([name, value]) => {
const contents = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(contents) > MODEL_MANIFEST_MAX_BYTES) {
throw new Error(`${name} exceeds the model manifest byte limit`);
}
return { name, contents };
});
}
7 changes: 6 additions & 1 deletion docs/internals/model-manifest.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Model manifest

`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from
`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic
`model-catalog.json` on Pylon's public releases repository at runtime. A remote fetch replaces
the in-memory and on-disk cache only after generic
catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the
last successful remote manifest. The bundle is used only when no valid remote cache exists.

Expand All @@ -18,6 +19,10 @@ a capability combination that does not already exist.
catalog discovery. New Claude models do not need to be added there. Codex still discovers models
from its app server and uses `currentModels.codex` only as a legacy-classification overlay.

Publication also produces `model-manifest.json` with only `version` and `currentModels` for
older releases whose strict schemas reject provider catalogs. Both files are generated from
the same validated source and published in one commit. See the [publishing runbook](../operations/model-manifest.md).

Claude model entries support:

- `aliases`, `status`, `badge`, and `profile` for client presentation and selection.
Expand Down
Loading
Loading