From 0f8af313ba76e877d65da6737a7063bf5a780776 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 9 Oct 2026 18:11:41 +0800 Subject: [PATCH] feat: update bundled model catalog from R2 CDN --- .github/workflows/release.yml | 5 +- build/build.mjs | 1 + build/docker-build.mjs | 1 + cdn/README.md | 45 ++++++++++ package.json | 11 +-- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 3 +- packages/core/src/gateway/model-catalog.ts | 9 +- packages/core/src/models/catalog-file.ts | 76 +++++++++++++++- packages/core/src/providers/model-catalog.ts | 10 ++- packages/core/src/web/management-server.ts | 2 + .../unit/models/model-catalog-file.test.mjs | 90 +++++++++++++++++++ packages/electron/src/main/main-app.ts | 2 + scripts/generate-models-json.mjs | 4 +- scripts/update-models-cdn.mjs | 59 ++++++++++++ 15 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 scripts/update-models-cdn.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d775974..da052453 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/build/build.mjs b/build/build.mjs index 2c8be592..7cbee17e 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -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"; diff --git a/build/docker-build.mjs b/build/docker-build.mjs index 762452b2..cfb862a4 100644 --- a/build/docker-build.mjs +++ b/build/docker-build.mjs @@ -1,3 +1,4 @@ +import "../scripts/generate-models-json.mjs"; import { buildBrowserRenderer, buildCoreServer, diff --git a/cdn/README.md b/cdn/README.md index 772e06ab..dc05e688 100644 --- a/cdn/README.md +++ b/cdn/README.md @@ -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 +`/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/). diff --git a/package.json b/package.json index dbf9ffe0..200f56f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 64a9653a..0b2e0371 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8dd140b6..48658abe 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 { const options = parseArgs(process.argv.slice(2)); + if (!options.help && options.command !== "stop") await refreshModelCatalog(); if (options.command === "start") { if (options.help) { printStartHelp(0); diff --git a/packages/core/src/gateway/model-catalog.ts b/packages/core/src/gateway/model-catalog.ts index 02e3e322..285a8c5e 100644 --- a/packages/core/src/gateway/model-catalog.ts +++ b/packages/core/src/gateway/model-catalog.ts @@ -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; @@ -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; } diff --git a/packages/core/src/models/catalog-file.ts b/packages/core/src/models/catalog-file.ts index 6bb0083c..4eef9df3 100644 --- a/packages/core/src/models/catalog-file.ts +++ b/packages/core/src/models/catalog-file.ts @@ -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 | 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 { + 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 { + 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"), diff --git a/packages/core/src/providers/model-catalog.ts b/packages/core/src/providers/model-catalog.ts index 45ea4a40..9cc9e3a0 100644 --- a/packages/core/src/providers/model-catalog.ts +++ b/packages/core/src/providers/model-catalog.ts @@ -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 } }; +let catalogRevision = -1; let catalogIndex: CatalogIndex | undefined; const catalogResultCache = new Map(); 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; } diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 776f1146..9135509f 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -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 = '