Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0a1007b
feat: refresh models from a hosted catalog
sethkarten Aug 31, 2026
e429b96
fix: make the hosted model list authoritative
sethkarten Sep 1, 2026
871be56
fix: accept catalog metadata on known provider routes
sethkarten Sep 1, 2026
ee1538d
fix: harden hosted catalog validation
sethkarten Sep 1, 2026
aeb795a
refactor: share environment flag parsing
sethkarten Sep 1, 2026
b299f64
fix: reject incompatible hosted catalogs
sethkarten Sep 1, 2026
e4e2861
fix: fail closed on an empty OpenRouter slice
sethkarten Sep 1, 2026
91ea1c7
fix: require catalog transport matches
sethkarten Sep 1, 2026
0634d93
refactor: share model catalog validation schemas
sethkarten Sep 1, 2026
73322c5
fix: preserve local compat validation behavior
sethkarten Sep 1, 2026
a1abe48
fix: validate every local compat field
sethkarten Sep 1, 2026
95dfe25
fix: harden hosted catalog refreshes
sethkarten Sep 1, 2026
97d6e39
fix: refresh future-dated model caches
sethkarten Sep 1, 2026
6bbf9e5
refactor: use Prime Inference model catalog
sethkarten Sep 6, 2026
9d4cea0
Merge remote-tracking branch 'origin/main' into eng-5435-hosted-model…
sethkarten Sep 6, 2026
32078d5
fix: preserve private model authorization fallbacks
sethkarten Sep 6, 2026
23a6114
test: allow live Prime model metadata
sethkarten Sep 6, 2026
973ae48
fix: secure live catalog generation
sethkarten Sep 6, 2026
66407b3
Merge remote-tracking branch 'origin/main' into eng-5435-hosted-model…
sethkarten Sep 6, 2026
e7e810c
chore: simplify live model catalog
sethkarten Sep 6, 2026
24d3413
fix: harden live model catalog fallbacks
sethkarten Sep 6, 2026
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
70 changes: 70 additions & 0 deletions .github/workflows/refresh-model-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Refresh hosted model catalog

on:
schedule:
# Daily, off the top of the hour to avoid the cron rush.
- cron: "17 5 * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: refresh-hosted-model-catalog
cancel-in-progress: false

env:
CATALOG_URL: ${{ vars.R2_PUBLIC_BASE_URL }}/model-catalog.json

jobs:
publish:
name: Generate, validate, and publish
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout default branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Load validation baseline
working-directory: packages/ai
run: |
if ! curl --fail --silent --show-error --location "$CATALOG_URL" --output /tmp/model-catalog-baseline.json; then
npm run export-model-catalog -- /tmp/model-catalog-baseline.json
fi

- name: Generate aggregate catalog
working-directory: packages/ai
env:
PRIME_AGENT_MODEL_CATALOG_OUTPUT: /tmp/model-catalog.json
run: npm run generate-models

- name: Validate catalog
working-directory: packages/ai
run: npm run validate-model-catalog -- /tmp/model-catalog.json /tmp/model-catalog-baseline.json

- name: Publish catalog to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
Comment thread
sethkarten marked this conversation as resolved.
Resolved
AWS_DEFAULT_REGION: auto
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
run: |
test -n "$R2_BUCKET"
test -n "$R2_ENDPOINT_URL"
aws s3 cp /tmp/model-catalog.json "s3://${R2_BUCKET}/model-catalog.json" \
--endpoint-url "$R2_ENDPOINT_URL" \
--content-type application/json \
--cache-control 'public, max-age=3600, must-revalidate'
1 change: 1 addition & 0 deletions packages/ai/.changes/hosted-model-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a validated aggregate model catalog for daily publication from live provider catalogs.
2 changes: 2 additions & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "npx tsx scripts/generate-models.ts",
"export-model-catalog": "npx tsx scripts/export-model-catalog.ts",
"validate-model-catalog": "npx tsx scripts/validate-model-catalog.ts",
"build": "npm run generate-models && tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
Expand Down
13 changes: 13 additions & 0 deletions packages/ai/scripts/export-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env tsx

import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Api, Model } from "../src/types.js";
import { MODELS } from "../src/models.generated.js";
import { createModelCatalog } from "./model-catalog-format.js";

const outputPath = process.argv[2];
if (!outputPath) throw new Error("Usage: export-model-catalog.ts <output-path>");
const models = Object.values(MODELS).flatMap((providerModels) => Object.values(providerModels)) as Model<Api>[];
writeFileSync(resolve(outputPath), `${JSON.stringify(createModelCatalog(models), null, 2)}\n`);
console.log(`Exported ${models.length} models to ${resolve(outputPath)}`);
12 changes: 12 additions & 0 deletions packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { getAnthropicCacheCosts } from "../src/cache-pricing.js";
import { getOpenRouterReasoningCapabilities } from "../src/openrouter-reasoning.js";
import { createModelCatalog } from "./model-catalog-format.js";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
Expand Down Expand Up @@ -2433,6 +2434,17 @@ export const MODELS = {
writeFileSync(join(packageRoot, "src/models.generated.ts"), output);
console.log("Generated src/models.generated.ts");

const catalogOutputPath = process.env.PRIME_AGENT_MODEL_CATALOG_OUTPUT?.trim();
if (catalogOutputPath) {
const uniqueModels = sortedProviderIds.flatMap((providerId) =>
Object.keys(providers[providerId])
.sort()
.map((modelId) => providers[providerId][modelId]),
);
writeFileSync(catalogOutputPath, `${JSON.stringify(createModelCatalog(uniqueModels), null, 2)}\n`);
console.log(`Generated ${catalogOutputPath}`);
}

// Print statistics
const totalModels = allModels.length;
const reasoningModels = allModels.filter(m => m.reasoning).length;
Expand Down
105 changes: 105 additions & 0 deletions packages/ai/scripts/model-catalog-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Api, Model } from "../src/types.js";

export const MODEL_CATALOG_SCHEMA_VERSION = 1 as const;
export const MAX_MODEL_CATALOG_MODELS = 20_000;

export interface ModelCatalogV1 {
schemaVersion: typeof MODEL_CATALOG_SCHEMA_VERSION;
generatedAt: string;
models: Model<Api>[];
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isFiniteNonNegative(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}

function isCatalogModel(value: unknown): value is Model<Api> {
if (!isRecord(value) || !isRecord(value.cost)) return false;
return (
typeof value.id === "string" &&
value.id.length > 0 &&
typeof value.name === "string" &&
value.name.length > 0 &&
typeof value.api === "string" &&
value.api.length > 0 &&
typeof value.provider === "string" &&
value.provider.length > 0 &&
typeof value.baseUrl === "string" &&
typeof value.reasoning === "boolean" &&
Array.isArray(value.input) &&
value.input.length > 0 &&
value.input.every((item) => item === "text" || item === "image") &&
isFiniteNonNegative(value.cost.input) &&
isFiniteNonNegative(value.cost.output) &&
isFiniteNonNegative(value.cost.cacheRead) &&
isFiniteNonNegative(value.cost.cacheWrite) &&
typeof value.contextWindow === "number" &&
Number.isSafeInteger(value.contextWindow) &&
value.contextWindow > 0 &&
typeof value.maxTokens === "number" &&
Number.isSafeInteger(value.maxTokens) &&
value.maxTokens > 0
);
}

function cloneModel(model: Model<Api>): Model<Api> {
return structuredClone(model);
}

export function createModelCatalog(models: readonly Model<Api>[], generatedAt = new Date()): ModelCatalogV1 {
const sorted = [...models]
.map(cloneModel)
.sort((left, right) => left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id));
return {
schemaVersion: MODEL_CATALOG_SCHEMA_VERSION,
generatedAt: generatedAt.toISOString(),
models: sorted,
};
}

export function parseModelCatalog(value: unknown): ModelCatalogV1 {
if (!isRecord(value) || value.schemaVersion !== MODEL_CATALOG_SCHEMA_VERSION) {
throw new Error("Model catalog has an unsupported schema version");
}
if (typeof value.generatedAt !== "string" || !Number.isFinite(Date.parse(value.generatedAt))) {
throw new Error("Model catalog has an invalid generatedAt timestamp");
}
if (!Array.isArray(value.models) || value.models.length === 0 || value.models.length > MAX_MODEL_CATALOG_MODELS) {
throw new Error("Model catalog has an invalid model count");
}

const seen = new Set<string>();
for (const model of value.models) {
if (!isCatalogModel(model)) throw new Error("Model catalog contains an invalid model");
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const key = `${model.provider}/${model.id}`;
if (seen.has(key)) throw new Error(`Model catalog contains duplicate model ${key}`);
seen.add(key);
}
return value as unknown as ModelCatalogV1;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

export function assertProviderCounts(
candidate: ModelCatalogV1,
baseline: ModelCatalogV1,
minimumRatio = 0.5,
): void {
if (!(minimumRatio > 0 && minimumRatio <= 1)) throw new Error("minimumRatio must be in (0, 1]");
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
const count = (catalog: ModelCatalogV1): Map<string, number> => {
const counts = new Map<string, number>();
for (const model of catalog.models) counts.set(model.provider, (counts.get(model.provider) ?? 0) + 1);
return counts;
};
const baselineCounts = count(baseline);
const candidateCounts = count(candidate);
for (const [provider, previous] of baselineCounts) {
const current = candidateCounts.get(provider) ?? 0;
const minimum = Math.max(1, Math.floor(previous * minimumRatio));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
if (current < minimum) {
throw new Error(`Provider ${provider} dropped from ${previous} to ${current} models (minimum ${minimum})`);
}
}
}
14 changes: 14 additions & 0 deletions packages/ai/scripts/validate-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env tsx

import { readFileSync } from "node:fs";
import { assertProviderCounts, parseModelCatalog } from "./model-catalog-format.js";

const [candidatePath, baselinePath] = process.argv.slice(2);
if (!candidatePath || !baselinePath) {
throw new Error("Usage: validate-model-catalog.ts <candidate-path> <baseline-path>");
}
const read = (path: string) => parseModelCatalog(JSON.parse(readFileSync(path, "utf8")) as unknown);
const candidate = read(candidatePath);
const baseline = read(baselinePath);
assertProviderCounts(candidate, baseline);
console.log(`Validated ${candidate.models.length} models against ${baseline.models.length} baseline models`);
47 changes: 47 additions & 0 deletions packages/ai/test/model-catalog-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import { assertProviderCounts, createModelCatalog, parseModelCatalog } from "../scripts/model-catalog-format.js";
import type { Api, Model } from "../src/types.js";

function model(provider: string, id: string): Model<Api> {
return {
id,
name: id,
api: "openai-completions",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
};
}

describe("hosted model catalog format", () => {
test("creates deterministic provider/model ordering", () => {
const generatedAt = new Date("2026-08-31T00:00:00.000Z");
const result = createModelCatalog([model("z", "b"), model("a", "z"), model("z", "a")], generatedAt);
expect(result.generatedAt).toBe(generatedAt.toISOString());
expect(result.models.map((entry) => `${entry.provider}/${entry.id}`)).toEqual(["a/z", "z/a", "z/b"]);
});

test("rejects duplicate entries and invalid prices", () => {
const entry = model("provider", "model");
expect(() =>
parseModelCatalog({ schemaVersion: 1, generatedAt: new Date().toISOString(), models: [entry, entry] }),
).toThrow("duplicate");
expect(() =>
parseModelCatalog({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
models: [{ ...entry, cost: { ...entry.cost, output: Number.NaN } }],
}),
).toThrow("invalid model");
});

test("fails closed on a provider-wide source outage", () => {
const baseline = createModelCatalog([model("large", "1"), model("large", "2"), model("small", "1")]);
const candidate = createModelCatalog([model("large", "1"), model("large", "2")]);
expect(() => assertProviderCounts(candidate, baseline)).toThrow("Provider small dropped");
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/hosted-model-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added daily runtime refreshes of provider model metadata and pricing from the hosted Prime Agent catalog, with validated disk and bundled fallbacks.
4 changes: 3 additions & 1 deletion packages/coding-agent/docs/providers.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Providers

Prime Agent supports subscription-based providers via OAuth and API key providers via environment variables or the auth file. Its built-in model catalog is updated with each Prime Agent release.
Prime Agent supports subscription-based providers via OAuth and API key providers via environment variables or the auth file. It refreshes provider model names, capabilities, and pricing from Prime Intellect's hosted catalog once per day. A validated disk cache and the catalog bundled with each release keep model selection available when the endpoint is offline. The hosted catalog cannot change provider request URLs, APIs, headers, or compatibility settings unless that transport already exists in the bundled catalog.

Set `PI_OFFLINE=1` to skip catalog network refreshes. Set `PRIME_AGENT_MODEL_CATALOG_URL` to use another catalog endpoint; `PRIME_AGENT_DOWNLOAD_BASE_URL` also changes the default catalog origin alongside the release origin.

## Table of Contents

Expand Down
Loading
Loading