Skip to content
Open
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
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 }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The R2 credential used here should be confirmed bucket-scoped and write-only, since a broader token would make this workflow the catalog's weakest link. Nothing in-repo can verify this — flagging for a one-time check in the Cloudflare dashboard.

[written by prime-agent, checked by snimu]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This cannot be verified from the repository. I am leaving this thread open and escalating a one-time Cloudflare dashboard check that the workflow credential is restricted to the intended bucket with the narrowest available object-write permission.

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
37 changes: 35 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 "../src/model-catalog.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 @@ -805,10 +814,12 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
models.push(normalizedModel);
}

if (models.length === 0) throw new Error("OpenRouter catalog has no tool-capable models");
console.log(`Fetched ${models.length} tool-capable models from OpenRouter`);
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.
return [];
}
}
Expand All @@ -817,7 +828,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 +877,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 +891,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 +1584,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 +2452,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 +2477,7 @@ export const MODELS = {
}

// Run the generator
generateModels().catch(console.error);
generateModels().catch((error) => {
console.error(error);
process.exitCode = 1;
});
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 "../src/model-catalog.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`);
2 changes: 2 additions & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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 { ProviderCompatSchema } from "./model-compat-schema.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
105 changes: 105 additions & 0 deletions packages/ai/src/model-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { type TProperties, Type } from "typebox";
import { Value } from "typebox/value";
import { isModelCompat } from "./model-compat-schema.js";
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 strictObject<T extends TProperties>(properties: T) {
return Type.Object(properties, { additionalProperties: false });
}

const ThinkingLevelValueSchema = Type.Union([Type.String({ minLength: 1, maxLength: 128 }), Type.Null()]);
const ThinkingLevelMapSchema = strictObject({
off: Type.Optional(ThinkingLevelValueSchema),
minimal: Type.Optional(ThinkingLevelValueSchema),
low: Type.Optional(ThinkingLevelValueSchema),
medium: Type.Optional(ThinkingLevelValueSchema),
high: Type.Optional(ThinkingLevelValueSchema),
xhigh: Type.Optional(ThinkingLevelValueSchema),
max: Type.Optional(ThinkingLevelValueSchema),
});
const CostSchema = Type.Number({ minimum: 0, maximum: 1_000_000 });
const CatalogModelSchema = strictObject({
id: Type.String({ minLength: 1, maxLength: 1_024 }),
name: Type.String({ minLength: 1, maxLength: 1_024 }),
api: Type.String({ minLength: 1, maxLength: 128 }),
provider: Type.String({ minLength: 1, maxLength: 128 }),
baseUrl: Type.String({ maxLength: 2_048 }),
reasoning: Type.Boolean(),
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]), { minItems: 1, maxItems: 2 }),
cost: Type.Object({
input: CostSchema,
output: CostSchema,
cacheRead: CostSchema,
cacheWrite: CostSchema,
}),
contextWindow: Type.Integer({ minimum: 1, maximum: 100_000_000 }),
maxTokens: Type.Integer({ minimum: 1, maximum: 100_000_000 }),
featured: Type.Optional(Type.Boolean()),
compat: Type.Optional(Type.Unknown()),
});
const ModelCatalogEnvelopeSchema = Type.Object({
schemaVersion: Type.Literal(MODEL_CATALOG_SCHEMA_VERSION),
generatedAt: Type.String(),
models: Type.Array(Type.Unknown(), { minItems: 1, maxItems: MAX_MODEL_CATALOG_MODELS }),
});

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

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

export function parseModelCatalog(value: unknown, options: { skipInvalidModels?: boolean } = {}): 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");
}
if (!Value.Check(ModelCatalogEnvelopeSchema, value)) throw new Error("Invalid model catalog entry");

const models: Model<Api>[] = [];
const seen = new Set<string>();
for (const candidate of value.models) {
if (!Value.Check(CatalogModelSchema, candidate)) {
if (options.skipInvalidModels) continue;
throw new Error("Invalid model catalog entry");
}
const model = candidate as Model<Api>;
if (!isModelCompat(model.api, model.compat)) {
if (options.skipInvalidModels) continue;
throw new Error("Invalid model catalog entry");
}
const key = JSON.stringify([model.provider, model.id]);
if (seen.has(key)) throw new Error(`Duplicate model catalog entry ${key}`);
seen.add(key);
models.push(model);
}
if (models.length === 0) throw new Error("Model catalog has no compatible entries");
return { schemaVersion: MODEL_CATALOG_SCHEMA_VERSION, generatedAt: value.generatedAt, models };
}
Loading
Loading