feat: update bundled model catalog from R2 CDN

This commit is contained in:
musistudio 2026-10-09 18:11:41 +08:00
parent 915c5f8e9a
commit 0f8af313ba
15 changed files with 307 additions and 13 deletions

View file

@ -26,7 +26,10 @@ jobs:
with:
node-version: 22
- name: Validate checked-in model catalog
- name: Generate model catalog
run: node scripts/generate-models-json.mjs
- name: Validate generated model catalog
run: node -e "JSON.parse(require('node:fs').readFileSync('packages/core/models.json', 'utf8'))"
- name: Upload model catalog

View file

@ -1,3 +1,4 @@
import "../scripts/generate-models-json.mjs";
import "./../scripts/generate-claude-code-config-options.mjs";
import { buildBrowserRenderer, buildMain, buildRenderer, buildRequestLogBodyWorker, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBundledClaudeRuntimePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs";

View file

@ -1,3 +1,4 @@
import "../scripts/generate-models-json.mjs";
import {
buildBrowserRenderer,
buildCoreServer,

View file

@ -61,3 +61,48 @@ The GitHub Actions workflow requires these repository secrets:
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_ID
```
## Model catalog on Cloudflare R2
The model catalog is hosted separately from the Pages assets. Builds and releases
still generate and bundle `models.json` as the offline baseline. CDN downloads
provide updates without requiring a new app release. CLI and Electron download it at startup
from `https://models.ccrdesk.top/models.json`, then check for updates at most hourly
when model metadata is used. A failed download preserves the last local cache at
`<runtime data directory>/cache/models.json`; without a cache the bundled catalog
is used, including on a fresh offline install. Existing explicit file overrides
(`CCR_MODEL_CATALOG_PATH` / `CCR_MODELS_JSON_PATH`) disable CDN downloads.
Create an R2 bucket and connect `models.ccrdesk.top` as its public custom domain
in the Cloudflare dashboard. This domain is independent of `cdn.ccrdesk.top`, which
remains connected to Pages. A different public R2 domain is supported by setting
`CCR_MODEL_CATALOG_URL` to its complete `models.json` URL in the app environment.
From the repository root:
```sh
# Generate the local catalog only (does not upload).
npm run models:build
# Configure the target bucket and authenticate Wrangler.
export CLOUDFLARE_R2_BUCKET=claude-code-router-models
npx wrangler@4 login
# Generate fresh data and update the remote R2 object.
npm run models:update
# Upload the existing catalog or validate without uploading.
npm run models:update -- --upload-only
npm run models:update -- --dry-run
```
The update command also loads the root `.env`. For CI, configure
`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN` (with R2 write permission), and
`CLOUDFLARE_R2_BUCKET`. No upload credentials are needed by the app.
`CCR_MODEL_CATALOG_R2_KEY` optionally changes the object key (default `models.json`);
set the runtime URL to match it. Generation failures abort the update so stale data
is not published. Uploaded objects use `application/json` and a five-minute cache
TTL; do not configure Cloudflare cache rules that override this TTL.
R2 setup and upload reference: [public custom domains](https://developers.cloudflare.com/r2/buckets/public-buckets/)
and [Wrangler R2 commands](https://developers.cloudflare.com/workers/wrangler/commands/r2/).

View file

@ -39,15 +39,14 @@
"build:docker": "node build/docker-build.mjs",
"build:app:mac": "npm run build:app:mac:local",
"build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never",
"build:app:mac:release": "node build/macos-release-preflight.mjs && npm run models:update && npm run build:assets && electron-builder --mac --publish never",
"build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never",
"build:app:win": "npm run build:app:win:local",
"build:app:win:local": "node build/windows-package-preflight.mjs && npm run build:assets && electron-builder --config build/electron-builder.local.cjs --win --publish never",
"build:app:win:release": "node build/windows-package-preflight.mjs && npm run build:assets && electron-builder --win",
"models:update": "node scripts/generate-models-json.mjs",
"prepack": "npm run models:update && npm run build:assets",
"models:update": "node scripts/update-models-cdn.mjs",
"prepack": "npm run build:assets",
"prepublishOnly": "npm run typecheck",
"preview": "npm run build:assets && electron .",
"version": "npm run models:update",
"bench:context-archive": "node benchmarks/run-context-archive-benchmark.mjs",
"bench:context-archive-agent": "node benchmarks/run-context-archive-real-agent-benchmark.mjs",
"bench:context-archive-real": "node benchmarks/run-context-archive-real-agent-benchmark.mjs",
@ -73,7 +72,9 @@
"test:renderer": "npm run test:ui",
"benchmark:request-logs": "node build/benchmark-request-logs.mjs",
"typecheck": "tsc --noEmit",
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3",
"models:build": "node scripts/generate-models-json.mjs",
"version": "npm run models:build"
},
"dependencies": {
"@the-next-ai/ai-gateway": "^1.0.21",

View file

@ -35,7 +35,7 @@
"access": "public"
},
"scripts": {
"prepack": "npm --prefix ../.. run models:update && npm --prefix ../.. run build:assets",
"prepack": "npm --prefix ../.. run build:assets",
"prepublishOnly": "npm --prefix ../.. run typecheck",
"test": "node ../../build/test.mjs cli && node ../../build/run-tests.mjs cli",
"test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli"

View file

@ -10,7 +10,7 @@ import { codexDesktopAppName, launchZcodeAppProfile } from "@ccr/core/agents/cod
import { loadAppConfig } from "@ccr/core/config/config";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { installSocketTypeOfServiceCompat } from "@ccr/core/platform/socket-compat";
import { resolveModelCatalogPath } from "@ccr/core/models/catalog-file";
import { refreshModelCatalog, resolveModelCatalogPath } from "@ccr/core/models/catalog-file";
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
import { ensureProfileGateway, ProfileGatewayUnavailableError } from "@ccr/core/profiles/launch-service";
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface, shouldAutoStartProfileGateway } from "@ccr/core/profiles/launch-core";
@ -73,6 +73,7 @@ const prepareProfileOnlyEnv = "CCR_CLI_PREPARE_PROFILE_ONLY";
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (!options.help && options.command !== "stop") await refreshModelCatalog();
if (options.command === "start") {
if (options.help) {
printStartHelp(0);

View file

@ -1,8 +1,9 @@
import { loadModelCatalogPayload } from "@ccr/core/models/catalog-file";
import { loadModelCatalogPayload, modelCatalogRevision, scheduleModelCatalogRefresh } from "@ccr/core/models/catalog-file";
import type { GatewayProviderConfig } from "@ccr/core/contracts/app";
import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
const claudeCodeDefaultContextTokens = 200_000;
let catalogRevision = -1;
let modelCatalogIndex: ModelCatalogIndex | undefined;
export type ModelCatalogCapabilities = Record<string, unknown>;
@ -213,6 +214,12 @@ export function modelCatalogReasoningEffortConfig(entry: ModelCatalogEntry | und
}
function loadModelCatalogIndex(): ModelCatalogIndex {
scheduleModelCatalogRefresh();
const currentRevision = modelCatalogRevision();
if (catalogRevision !== currentRevision) {
catalogRevision = currentRevision;
modelCatalogIndex = undefined;
}
if (modelCatalogIndex) {
return modelCatalogIndex;
}

View file

@ -1,5 +1,77 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname, resolve as pathResolve } from "node:path";
import { resolveRuntimeDataDir } from "@ccr/core/runtime/app-paths";
export const DEFAULT_MODEL_CATALOG_URL = "https://models.ccrdesk.top/models.json";
const refreshIntervalMs = 60 * 60 * 1000;
let revision = 0;
let refreshStarted = false;
let lastAttempt = 0;
let pendingRefresh: Promise<void> | undefined;
let remoteCatalog: LoadedModelCatalogPayload | undefined;
export function scheduleModelCatalogRefresh(): void {
if (refreshStarted) void refreshModelCatalog();
}
export function modelCatalogRevision(): number {
return revision;
}
export function modelCatalogCachePath(): string {
return pathResolve(resolveRuntimeDataDir(), "cache", "models.json");
}
function hasLocalOverride(): boolean {
return Boolean(process.env.CCR_MODEL_CATALOG_PATH?.trim() || process.env.CCR_MODELS_JSON_PATH?.trim());
}
/** Download once at startup and at most hourly thereafter. Local overrides stay offline. */
export function refreshModelCatalog(): Promise<void> {
refreshStarted = true;
if (hasLocalOverride()) return Promise.resolve();
if (pendingRefresh) return pendingRefresh;
if (Date.now() - lastAttempt < refreshIntervalMs) return Promise.resolve();
lastAttempt = Date.now();
pendingRefresh = downloadModelCatalog().finally(() => { pendingRefresh = undefined; });
return pendingRefresh;
}
async function downloadModelCatalog(): Promise<void> {
const url = process.env.CCR_MODEL_CATALOG_URL?.trim() || DEFAULT_MODEL_CATALOG_URL;
try {
const response = await fetch(url, {
headers: { accept: "application/json", "cache-control": "no-cache" },
signal: AbortSignal.timeout(10_000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload: unknown = await response.json();
if (!isModelCatalogPayload(payload)) throw new Error("Invalid model catalog (expected schemaVersion 2 and non-empty model records)");
remoteCatalog = { loadedFrom: url, payload };
revision += 1;
const file = modelCatalogCachePath();
const temporaryFile = `${file}.${randomUUID()}.tmp`;
try {
await mkdir(dirname(file), { recursive: true });
await writeFile(temporaryFile, JSON.stringify(payload), "utf8");
await rename(temporaryFile, file);
} finally {
await rm(temporaryFile, { force: true });
}
} catch (error) {
console.warn(`[models] CDN refresh failed; using available local catalog: ${error instanceof Error ? error.message : String(error)}`);
}
}
function isModelCatalogPayload(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const catalog = value as { schemaVersion?: unknown; models?: unknown };
return catalog.schemaVersion === 2 && Array.isArray(catalog.models) && catalog.models.length > 0 &&
catalog.models.every((model: unknown) => Boolean(model && typeof model === "object" &&
typeof (model as { id?: unknown }).id === "string"));
}
export type LoadedModelCatalogPayload = {
loadedFrom: string;
@ -7,6 +79,7 @@ export type LoadedModelCatalogPayload = {
};
export function loadModelCatalogPayload(): LoadedModelCatalogPayload | undefined {
if (!hasLocalOverride() && remoteCatalog) return remoteCatalog;
const candidate = resolveModelCatalogPath();
return candidate
? {
@ -24,6 +97,7 @@ export function modelCatalogPathCandidates(): string[] {
return uniqueStrings([
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
modelCatalogCachePath(),
pathResolve(process.cwd(), "models.json"),
pathResolve(process.cwd(), "packages", "core", "models.json"),
pathResolve(process.cwd(), "packages", "cli", "models.json"),

View file

@ -5,7 +5,7 @@ import type {
ProviderModelPricing
} from "@ccr/core/contracts/app";
import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
import { loadModelCatalogPayload } from "@ccr/core/models/catalog-file";
import { loadModelCatalogPayload, modelCatalogRevision, scheduleModelCatalogRefresh } from "@ccr/core/models/catalog-file";
import { findProviderPreset, findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index";
type CatalogProviderEntry = {
@ -70,6 +70,7 @@ const presetCatalogModelOverrides: Record<string, CatalogProviderModelOverride>
}
};
let catalogRevision = -1;
let catalogIndex: CatalogIndex | undefined;
const catalogResultCache = new Map<string, ProviderCatalogModelsResult>();
const CATALOG_RESULT_CACHE_LIMIT = 512;
@ -169,6 +170,13 @@ function catalogOverrideModelMetadata(
}
function loadCatalogIndex(): CatalogIndex {
scheduleModelCatalogRefresh();
const currentRevision = modelCatalogRevision();
if (catalogRevision !== currentRevision) {
catalogRevision = currentRevision;
catalogIndex = undefined;
catalogResultCache.clear();
}
if (catalogIndex) {
return catalogIndex;
}

View file

@ -1,3 +1,4 @@
import { refreshModelCatalog } from "@ccr/core/models/catalog-file";
import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
@ -132,6 +133,7 @@ const webBridgeScriptTag = ' <script src="../../assets/web-client-bridge.js">
export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise<WebManagementServerRuntime> {
await refreshModelCatalog();
const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost;
const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort;
const authToken = options.authToken?.trim() || readEnvString("CCR_WEB_AUTH_TOKEN") || randomBytes(32).toString("base64url");

View file

@ -60,3 +60,93 @@ test("loadModelCatalogPayload reads the first configured existing catalog", () =
}
}
});
test("a fresh offline install resolves the bundled catalog without a CDN cache", () => {
const keys = ["CCR_MODEL_CATALOG_PATH", "CCR_MODELS_JSON_PATH", "CCR_INTERNAL_USER_DATA_DIR"];
const previous = keys.map((key) => process.env[key]);
const dir = mkdtempSync(path.join(tmpdir(), "ccr-catalog-offline-test-"));
try {
delete process.env.CCR_MODEL_CATALOG_PATH;
delete process.env.CCR_MODELS_JSON_PATH;
process.env.CCR_INTERNAL_USER_DATA_DIR = dir;
const loaded = loadModelCatalogPayload();
assert.ok(loaded);
assert.notEqual(loaded.loadedFrom, path.join(dir, "cache", "models.json"));
assert.equal(loaded.payload.schemaVersion, 2);
assert.ok(loaded.payload.models.length > 0);
} finally {
keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; });
rmSync(dir, { recursive: true, force: true });
}
});
test("CDN catalog refresh deduplicates requests, updates indexes and preserves cache on failure", async () => {
const { readFileSync, readdirSync } = await import("node:fs");
const { refreshModelCatalog, modelCatalogCachePath, modelCatalogRevision } = await import("@ccr/core/models/catalog-file.ts");
const { findModelCatalogEntry } = await import("@ccr/core/gateway/model-catalog.ts");
const { getProviderCatalogModels } = await import("@ccr/core/providers/model-catalog.ts");
const dir = mkdtempSync(path.join(tmpdir(), "ccr-cdn-catalog-test-"));
const keys = ["CCR_MODEL_CATALOG_PATH", "CCR_MODELS_JSON_PATH", "CCR_MODEL_CATALOG_URL", "CCR_INTERNAL_USER_DATA_DIR"];
const previous = keys.map((key) => process.env[key]);
const originalFetch = globalThis.fetch;
const originalNow = Date.now;
const originalWarn = console.warn;
let time = originalNow();
let calls = 0;
const model = (id) => ({ id, model: id, providers: ["cdn-test"], limits: { contextTokens: 12345 },
modalities: { input: ["text"], output: ["text"] },
sourceRecords: [{ provider: "cdn-test", model: id, modalities: { input: ["text"], output: ["text"] } }] });
const payload = { schemaVersion: 2, models: [model("cdn-first")] };
try {
delete process.env.CCR_MODEL_CATALOG_PATH;
delete process.env.CCR_MODELS_JSON_PATH;
process.env.CCR_INTERNAL_USER_DATA_DIR = dir;
process.env.CCR_MODEL_CATALOG_URL = "https://test.invalid/models.json";
Date.now = () => time;
console.warn = () => {};
globalThis.fetch = async (url, options) => {
calls += 1;
assert.equal(url, process.env.CCR_MODEL_CATALOG_URL);
assert.ok(options.signal);
return new Response(JSON.stringify(payload));
};
// Prime both consumers before the download to verify cache invalidation.
assert.equal(findModelCatalogEntry("cdn-first"), undefined);
assert.deepEqual(getProviderCatalogModels({ providerIds: ["cdn-test"] }).models, []);
await Promise.all([refreshModelCatalog(), refreshModelCatalog()]);
assert.equal(calls, 1);
assert.deepEqual(JSON.parse(readFileSync(modelCatalogCachePath(), "utf8")), payload);
assert.equal(findModelCatalogEntry("cdn-first")?.limits.contextTokens, 12345);
assert.deepEqual(getProviderCatalogModels({ providerIds: ["cdn-test"] }).models, ["cdn-first"]);
await refreshModelCatalog();
assert.equal(calls, 1);
time += 60 * 60 * 1000 + 1;
payload.models = [model("cdn-second")];
await refreshModelCatalog();
assert.equal(findModelCatalogEntry("cdn-first"), undefined);
assert.ok(findModelCatalogEntry("cdn-second"));
assert.deepEqual(getProviderCatalogModels({ providerIds: ["cdn-test"] }).models, ["cdn-second"]);
const goodRevision = modelCatalogRevision();
for (const response of [() => new Response("{}"), () => new Response("bad", { status: 503 }), () => { throw new Error("offline"); }]) {
time += 60 * 60 * 1000 + 1;
globalThis.fetch = async () => response();
await refreshModelCatalog();
assert.equal(modelCatalogRevision(), goodRevision);
assert.deepEqual(JSON.parse(readFileSync(modelCatalogCachePath(), "utf8")), payload);
assert.ok(findModelCatalogEntry("cdn-second"));
}
assert.deepEqual(readdirSync(path.dirname(modelCatalogCachePath())), ["models.json"]);
process.env.CCR_MODEL_CATALOG_PATH = path.join(dir, "override.json");
writeFileSync(process.env.CCR_MODEL_CATALOG_PATH, JSON.stringify({ models: [{ id: "override" }] }));
time += 60 * 60 * 1000 + 1;
globalThis.fetch = async () => { assert.fail("Local overrides must not fetch CDN"); };
await refreshModelCatalog();
assert.equal(loadModelCatalogPayload().payload.models[0].id, "override");
} finally {
keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; });
globalThis.fetch = originalFetch;
Date.now = originalNow;
console.warn = originalWarn;
rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -1,3 +1,4 @@
import { refreshModelCatalog } from "@ccr/core/models/catalog-file";
import { app, BrowserWindow, dialog, shell } from "electron";
import { setupApplicationMenu } from "./app-menu";
import { loadAppConfig } from "@ccr/core/config/config";
@ -47,6 +48,7 @@ function startPrimaryInstance(): void {
});
void app.whenReady().then(async () => {
await refreshModelCatalog();
const config = await loadAppConfig();
applyNativeThemePreference(config.theme);
windowsManager.setOnboardingFinished(await loadOnboardingFinished());

View file

@ -102,7 +102,7 @@ async function main() {
);
if (failures.length > 0) {
const existingCatalog = await readExistingCatalog();
if (existingCatalog) {
if (existingCatalog && !process.argv.includes("--strict")) {
console.warn(`[models] Refresh failed (${failures.join("; ")}).`);
console.warn(
`[models] Keeping the checked-in catalog with ${existingCatalog.models.length} model records.`
@ -1204,7 +1204,7 @@ function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
main().catch((error) => {
await main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogFile = path.join(projectRoot, "packages/core/models.json");
const args = process.argv.slice(2);
async function main() {
if (args.includes("--help")) {
console.log("Usage: npm run models:update -- [--upload-only] [--dry-run]\nGenerates models.json and uploads it to Cloudflare R2.\nRequired: CLOUDFLARE_R2_BUCKET; authenticate Wrangler or set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.\n--upload-only: upload the existing catalog without regenerating.\n--dry-run: validate configuration/catalog and print the upload command without generating or uploading.");
return;
}
for (const arg of args) {
if (!["--upload-only", "--dry-run"].includes(arg)) throw new Error(`Unknown option: ${arg}`);
}
try { process.loadEnvFile(path.join(projectRoot, ".env")); } catch (error) {
if (error.code !== "ENOENT") throw error;
}
const bucket = process.env.CLOUDFLARE_R2_BUCKET?.trim();
const key = process.env.CCR_MODEL_CATALOG_R2_KEY?.trim() || "models.json";
if (!bucket || !/^[a-z0-9][a-z0-9-]*$/.test(bucket)) throw new Error("Set CLOUDFLARE_R2_BUCKET to your R2 bucket name.");
if (key.startsWith("/") || key.includes("\\") || key.split("/").includes("..")) throw new Error("CCR_MODEL_CATALOG_R2_KEY must be a relative R2 object key.");
if (!args.includes("--upload-only") && !args.includes("--dry-run")) {
run(process.execPath, ["scripts/generate-models-json.mjs", "--strict"]);
}
const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
if (catalog.schemaVersion !== 2 || !Array.isArray(catalog.models) || !catalog.models.length ||
!catalog.models.every((model) => model && typeof model.id === "string")) {
throw new Error("Invalid models.json; run npm run models:build first.");
}
const command = ["--yes", "wrangler@4", "r2", "object", "put", `${bucket}/${key}`, "--remote",
"--file", catalogFile, "--content-type", "application/json", "--cache-control", "public, max-age=300, must-revalidate"];
if (args.includes("--dry-run")) {
console.log(command.map((arg) => JSON.stringify(arg)).join(" "));
console.log(`Validated ${catalog.models.length} models. No upload performed.`);
return;
}
if (process.platform === "win32") {
if (!process.env.npm_execpath) throw new Error("Run this command through npm run models:update on Windows.");
run(process.execPath, [path.join(path.dirname(process.env.npm_execpath), "npx-cli.js"), ...command]);
} else {
run("npx", command);
}
console.log(`Updated R2 object ${bucket}/${key} (${catalog.models.length} models).`);
}
function run(command, commandArgs) {
const result = spawnSync(command, commandArgs, { cwd: projectRoot, env: process.env, stdio: "inherit" });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${path.basename(command)} failed (${result.status ?? result.signal}).`);
}
main().catch((error) => {
console.error(`[models:update] ${error.message}`);
process.exitCode = 1;
});