mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-19 08:54:05 +00:00
feat: update bundled model catalog from R2 CDN
This commit is contained in:
parent
915c5f8e9a
commit
0f8af313ba
15 changed files with 307 additions and 13 deletions
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "../scripts/generate-models-json.mjs";
|
||||
import {
|
||||
buildBrowserRenderer,
|
||||
buildCoreServer,
|
||||
|
|
|
|||
|
|
@ -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/).
|
||||
|
|
|
|||
11
package.json
11
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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
59
scripts/update-models-cdn.mjs
Normal file
59
scripts/update-models-cdn.mjs
Normal 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;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue