Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 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.
Outdated
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.
Outdated
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