Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
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
61 changes: 61 additions & 0 deletions .github/workflows/refresh-model-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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

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: Generate aggregate catalog
working-directory: packages/ai
env:
PRIME_AGENT_MODEL_CATALOG_OUTPUT: /tmp/model-catalog.json
PRIME_AGENT_MODEL_CATALOG_STRICT: "1"
run: npm run generate-models

- name: Validate catalog
working-directory: packages/ai
run: npm run validate-model-catalog -- /tmp/model-catalog.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.
1 change: 1 addition & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "npx tsx scripts/generate-models.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
36 changes: 34 additions & 2 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 @@ -76,6 +77,7 @@ const KIMI_STATIC_HEADERS = {

const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1";
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
const STRICT_MODEL_CATALOG_REFRESH = process.env.PRIME_AGENT_MODEL_CATALOG_STRICT === "1";
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([
"github-copilot:claude-haiku-4.5",
Expand Down Expand Up @@ -651,20 +653,25 @@ async function fetchPrimeInferenceModels(): Promise<Model<"openai-completions">[
const response = await fetch(`${PRIME_INFERENCE_BASE_URL}/models`, {
headers: getPrimeInferenceHeaders(apiKey, teamId),
});
if (!response.ok) throw new Error(`Prime Inference catalog request failed with status ${response.status}`);
catalog = parsePrimeInferenceCatalog(await response.json());
if (catalog.length === 0) throw new Error("Prime Inference catalog is empty or invalid");
} catch (error) {
console.error("Failed to fetch Prime Inference models:", error);
if (STRICT_MODEL_CATALOG_REFRESH) throw error;
}

let openRouterIndex = new Map<string, PrimeInferenceOpenRouterMetadata>();
try {
openRouterIndex = buildPrimeInferenceOpenRouterIndex(await fetchOpenRouterCatalog());
} catch (error) {
console.error("Failed to fetch OpenRouter catalog for Prime Inference metadata:", error);
if (STRICT_MODEL_CATALOG_REFRESH) throw error;
}
if (openRouterIndex.size === 0) {
// Without OpenRouter metadata every model would regress to the defaults;
// keep the previous snapshot instead.
if (STRICT_MODEL_CATALOG_REFRESH) throw new Error("OpenRouter catalog has no Prime Inference metadata");
console.error("OpenRouter catalog unavailable; keeping snapshot Prime Inference models");
return getExistingPrimeInferenceModels();
}
Expand Down Expand Up @@ -743,8 +750,10 @@ function fetchOpenRouterCatalog(): Promise<any[]> {
openRouterCatalogPromise ??= (async () => {
console.log("Fetching models from OpenRouter API...");
const response = await fetch("https://openrouter.ai/api/v1/models");
if (!response.ok) throw new Error(`OpenRouter catalog request failed with status ${response.status}`);
const data = await response.json();
return Array.isArray(data?.data) ? data.data : [];
if (!Array.isArray(data?.data) || data.data.length === 0) throw new Error("OpenRouter catalog is empty or invalid");
return data.data;
})();
return openRouterCatalogPromise;
}
Expand Down Expand Up @@ -809,6 +818,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
return models;
} catch (error) {
console.error("Failed to fetch OpenRouter models:", error);
if (STRICT_MODEL_CATALOG_REFRESH) throw error;
Comment thread
sethkarten marked this conversation as resolved.
Resolved
return [];
}
}
Expand All @@ -817,7 +827,9 @@ async function fetchAiGatewayModels(): Promise<Model<any>[]> {
try {
console.log("Fetching models from Vercel AI Gateway API...");
const response = await fetch(`${AI_GATEWAY_MODELS_URL}/models`);
if (!response.ok) throw new Error(`Vercel AI Gateway catalog request failed with status ${response.status}`);
const data = await response.json();
if (!Array.isArray(data?.data)) throw new Error("Vercel AI Gateway catalog is invalid");
const models: Model<any>[] = [];

const toNumber = (value: string | number | undefined): number => {
Expand Down Expand Up @@ -864,10 +876,12 @@ async function fetchAiGatewayModels(): Promise<Model<any>[]> {
});
}

if (models.length === 0) throw new Error("Vercel AI Gateway catalog has no tool-capable models");
console.log(`Fetched ${models.length} tool-capable models from Vercel AI Gateway`);
return models;
} catch (error) {
console.error("Failed to fetch Vercel AI Gateway models:", error);
if (STRICT_MODEL_CATALOG_REFRESH) throw error;
return [];
}
}
Expand All @@ -876,7 +890,9 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
try {
console.log("Fetching models from models.dev API...");
const response = await fetch("https://models.dev/api.json");
if (!response.ok) throw new Error(`models.dev catalog request failed with status ${response.status}`);
const data = await response.json();
if (!isRecord(data)) throw new Error("models.dev catalog is invalid");

const models: Model<any>[] = [];

Expand Down Expand Up @@ -1567,10 +1583,12 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}

if (models.length === 0) throw new Error("models.dev catalog has no tool-capable models");
console.log(`Loaded ${models.length} tool-capable models from models.dev`);
return models;
} catch (error) {
console.error("Failed to load models.dev data:", error);
if (STRICT_MODEL_CATALOG_REFRESH) throw error;
return [];
}
}
Expand Down Expand Up @@ -2433,6 +2451,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 All @@ -2447,4 +2476,7 @@ export const MODELS = {
}

// Run the generator
generateModels().catch(console.error);
generateModels().catch((error) => {
console.error(error);
process.exitCode = 1;
});
2 changes: 2 additions & 0 deletions packages/ai/scripts/model-catalog-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createModelCatalog, parseModelCatalog } from "../src/model-catalog.js";
export type { ModelCatalogV1 } from "../src/model-catalog.js";
9 changes: 9 additions & 0 deletions packages/ai/scripts/validate-model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env tsx

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

const [candidatePath] = process.argv.slice(2);
if (!candidatePath) throw new Error("Usage: validate-model-catalog.ts <candidate-path>");
const candidate = parseModelCatalog(JSON.parse(readFileSync(candidatePath, "utf8")) as unknown);
console.log(`Validated ${candidate.models.length} models`);
1 change: 1 addition & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { Type } from "typebox";
export * from "./api-registry.js";
export * from "./env-api-keys.js";
export * from "./log.js";
export * from "./model-catalog.js";
export * from "./models.js";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js";
Expand Down
109 changes: 109 additions & 0 deletions packages/ai/src/model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Api, Model } from "./types.js";

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

export interface ModelCatalogV1 {
schemaVersion: typeof MODEL_CATALOG_SCHEMA_VERSION;
generatedAt: string;
models: Model<Api>[];
}

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

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

function isNonEmptyString(value: unknown, maxLength: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= maxLength;
}

function isBoundedJsonObject(value: unknown, depth = 0): boolean {
if (!isRecord(value) || depth > 10 || Object.keys(value).length > 100) return false;
return Object.entries(value).every(([key, entry]) => {
if (key.length > 128) return false;
if (entry === null || typeof entry === "boolean") return true;
if (typeof entry === "string") return entry.length <= 4_096;
if (typeof entry === "number") return Number.isFinite(entry);
if (Array.isArray(entry))
return entry.length <= 100 && entry.every((item) => isBoundedJsonObject({ item }, depth + 1));
return isBoundedJsonObject(entry, depth + 1);
});
}

const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);

function isThinkingLevelMap(value: unknown): boolean {
if (value === undefined) return true;
if (!isRecord(value) || Object.keys(value).some((key) => !THINKING_LEVELS.has(key))) return false;
return Object.values(value).every(
(entry) => entry === null || (typeof entry === "string" && entry.length > 0 && entry.length <= 128),
);
}

function isCatalogModel(value: unknown): value is Model<Api> {
if (!isRecord(value) || !isRecord(value.cost)) return false;
return (
isNonEmptyString(value.id, 1_024) &&
isNonEmptyString(value.name, 1_024) &&
isNonEmptyString(value.api, 128) &&
isNonEmptyString(value.provider, 128) &&
typeof value.baseUrl === "string" &&
value.baseUrl.length <= 2_048 &&
typeof value.reasoning === "boolean" &&
isThinkingLevelMap(value.thinkingLevelMap) &&
Array.isArray(value.input) &&
value.input.length > 0 &&
value.input.length <= 2 &&
value.input.every((item) => item === "text" || item === "image") &&
isFiniteCost(value.cost.input) &&
isFiniteCost(value.cost.output) &&
isFiniteCost(value.cost.cacheRead) &&
isFiniteCost(value.cost.cacheWrite) &&
typeof value.contextWindow === "number" &&
Number.isSafeInteger(value.contextWindow) &&
value.contextWindow > 0 &&
value.contextWindow <= 100_000_000 &&
typeof value.maxTokens === "number" &&
Number.isSafeInteger(value.maxTokens) &&
value.maxTokens > 0 &&
value.maxTokens <= 100_000_000 &&
(value.featured === undefined || typeof value.featured === "boolean") &&
(value.headers === undefined || isBoundedJsonObject(value.headers)) &&
(value.compat === undefined || isBoundedJsonObject(value.compat))
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
);
}

export function createModelCatalog(models: readonly Model<Api>[], generatedAt = new Date()): ModelCatalogV1 {
const sorted = [...models]
.map((model) => structuredClone(model))
.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("Unsupported model catalog schema version");
}
if (typeof value.generatedAt !== "string" || !Number.isFinite(Date.parse(value.generatedAt))) {
throw new Error("Invalid model catalog timestamp");
}
if (!Array.isArray(value.models) || value.models.length === 0 || value.models.length > MAX_MODEL_CATALOG_MODELS) {
throw new Error("Invalid model catalog model count");
}
const seen = new Set<string>();
for (const model of value.models) {
if (!isCatalogModel(model)) throw new Error("Invalid model catalog entry");
const key = `${model.provider}/${model.id}`;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
if (seen.has(key)) throw new Error(`Duplicate model catalog entry ${key}`);
seen.add(key);
}
return value as unknown as ModelCatalogV1;
}
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 { 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("applies the same strict schema used by clients", () => {
const entry = model("provider", "model");
const catalog = (modelEntry: Model<Api>) => ({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
models: [modelEntry],
});
expect(() => parseModelCatalog({ ...catalog(entry), models: [entry, entry] })).toThrow(/duplicate/i);
expect(() => parseModelCatalog(catalog({ ...entry, cost: { ...entry.cost, output: Number.NaN } }))).toThrow(
/invalid model/i,
);
expect(() => parseModelCatalog(catalog({ ...entry, cost: { ...entry.cost, input: 1_000_001 } }))).toThrow(
/invalid model/i,
);
expect(() => parseModelCatalog(catalog({ ...entry, contextWindow: 100_000_001 }))).toThrow(/invalid model/i);
expect(() =>
parseModelCatalog(catalog({ ...entry, thinkingLevelMap: { unsupported: "value" } } as Model<Api>)),
).toThrow(/invalid model/i);
});
});
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 authoritative refreshes of provider model availability, 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 treats Prime Intellect's hosted catalog as the authoritative public model list and refreshes model additions, removals, names, capabilities, and pricing 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