diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 409612de..c3020fef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,8 +13,33 @@ concurrency: cancel-in-progress: false jobs: + model-catalog: + name: Update model catalog + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Update models.json + run: npm run models:update + + - name: Upload model catalog + uses: actions/upload-artifact@v4 + with: + name: release-model-catalog + path: packages/core/models.json + if-no-files-found: error + retention-days: 1 + macos: name: macOS (${{ matrix.display_name }}) + needs: model-catalog runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -45,6 +70,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Download updated model catalog + uses: actions/download-artifact@v4 + with: + name: release-model-catalog + path: packages/core + - name: Verify tag version shell: bash run: | @@ -155,6 +186,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Download updated model catalog + uses: actions/download-artifact@v4 + with: + name: release-model-catalog + path: packages/core + - name: Verify tag version shell: bash run: | @@ -189,6 +226,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Download updated model catalog + uses: actions/download-artifact@v4 + with: + name: release-model-catalog + path: packages/core + - name: Verify tag version shell: bash run: | diff --git a/benchmarks/context-archive-benchmark.ts b/benchmarks/context-archive-benchmark.ts index 5d99916a..edeb60b6 100644 --- a/benchmarks/context-archive-benchmark.ts +++ b/benchmarks/context-archive-benchmark.ts @@ -310,9 +310,7 @@ function buildScenario(strategy: StrategyId, corpus: Corpus, iteration: number): } function benchmarkConfig(): AppConfig { - const config = createDefaultAppConfig({ - generatedConfigFile: "/tmp/ccr-context-archive-benchmark-gateway.json" - }); + const config = createDefaultAppConfig(); return { ...config, APIKEY: "benchmark-key", diff --git a/benchmarks/context-archive-real-agent-benchmark.ts b/benchmarks/context-archive-real-agent-benchmark.ts index f694f8d2..fa0cce73 100644 --- a/benchmarks/context-archive-real-agent-benchmark.ts +++ b/benchmarks/context-archive-real-agent-benchmark.ts @@ -664,9 +664,7 @@ function normalizeMatchText(value: string): string { } function benchmarkConfig(): AppConfig { - const config = createDefaultAppConfig({ - generatedConfigFile: "/tmp/ccr-context-archive-real-agent-benchmark.json" - }); + const config = createDefaultAppConfig(); return { ...config, APIKEY: "benchmark-key", diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 46581e80..e92ec79a 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -226,6 +226,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { path.join(electronSourceRoot, "main", "main.ts"), path.join(electronSourceRoot, "main", "browser-preload.ts"), gatewayRuntimeInput, + path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"), path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), @@ -258,6 +259,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} entryNames: "[name]", entryPoints: [ path.join(cliSourceRoot, "cli.ts"), + path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"), path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"), @@ -286,6 +288,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = [] entryNames: "[name]", entryPoints: [ path.join(coreSourceRoot, "entrypoints", "server.ts"), + path.join(coreSourceRoot, "gateway", "core-runtime", "gateway-bootstrap.ts"), path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), path.join(coreSourceRoot, "mcp", "media-tools-proxy-mcp.ts"), diff --git a/build/test.mjs b/build/test.mjs index 0317d1ee..81ac359f 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -26,6 +26,7 @@ const testProjects = { core: { runtimeEntryPoints: { "runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"), + "runtime/gateway-bootstrap": path.join(packageRoots.core, "gateway", "core-runtime", "gateway-bootstrap.ts"), "runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"), "runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"), "runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"), diff --git a/package-lock.json b/package-lock.json index 0fa2f0ca..49b98cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "electron": "^42.3.3", "electron-builder": "^26.8.1", "esbuild": "^0.27.7", + "js-yaml": "^4.2.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "react": "^18.3.1", diff --git a/package.json b/package.json index 34417587..eb18d6dc 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "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 build:assets && electron-builder --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:win": "npm run build:assets && electron-builder --win", - "prepack": "npm run build:assets", + "models:update": "node scripts/generate-models-json.mjs", + "prepack": "npm run models:update && 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", @@ -92,6 +94,7 @@ "electron": "^42.3.3", "electron-builder": "^26.8.1", "esbuild": "^0.27.7", + "js-yaml": "^4.2.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "react": "^18.3.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index f54c7444..c0fd2f6d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "access": "public" }, "scripts": { - "prepack": "npm --prefix ../.. run build:assets", + "prepack": "npm --prefix ../.. run models:update && 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/core/src/agents/zcode/profile-config.ts b/packages/core/src/agents/zcode/profile-config.ts index 49ab5325..b1a7eb86 100644 --- a/packages/core/src/agents/zcode/profile-config.ts +++ b/packages/core/src/agents/zcode/profile-config.ts @@ -22,7 +22,6 @@ type ZcodeGatewayConfigValues = { }; const legacyZcodeTomlConfigFile = "~/.zcode/config.toml"; -const defaultZcodeConfigFile = "~/.zcode/cli/config.json"; const originalBackupSuffix = ".ccr-original"; const originalMissingSuffix = ".ccr-original-missing"; diff --git a/packages/core/src/config/api-key-store.ts b/packages/core/src/config/api-key-store.ts deleted file mode 100644 index 76fca9c5..00000000 --- a/packages/core/src/config/api-key-store.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { chmodSync, existsSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { API_KEYS_DB_FILE, LEGACY_API_KEYS_DB_FILES } from "@ccr/core/config/constants"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; -import type { ApiKeyConfig, ApiKeyLimitConfig } from "@ccr/core/contracts/app"; - -type SqlDatabase = BetterSqliteDatabase; -type SqlValue = bigint | Buffer | number | string | null; - -type StoredApiKeyRow = { - createdAt: string; - encryption: string; - expiresAt: string; - id: string; - limitsJson: string; - name: string; - storedKey: string; -}; - -const plainStorage = "plain"; -const privateDirMode = 0o700; -const privateFileMode = 0o600; - -class ApiKeyStore { - private database?: SqlDatabase; - private initPromise?: Promise; - - constructor(private readonly dbFile: string) {} - - async list(): Promise { - const database = await this.getDatabase(); - const rows = queryRows( - database, - ` - SELECT - id, - name, - encrypted_key, - encryption, - created_at, - expires_at, - limits_json - FROM api_keys - ORDER BY rowid - ` - ); - - return uniqueApiKeyConfigs(rows.map(toApiKeyConfig)); - } - - async replace(apiKeys: ApiKeyConfig[]): Promise { - const normalized = uniqueApiKeyConfigs(apiKeys); - const database = await this.getDatabase(); - const statement = database.prepare(` - INSERT INTO api_keys ( - id, - name, - encrypted_key, - encryption, - created_at, - expires_at, - limits_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - try { - database.exec("BEGIN TRANSACTION"); - database.exec("DELETE FROM api_keys"); - for (const apiKey of normalized) { - const stored = storeApiKey(apiKey.key); - statement.run( - apiKey.id, - apiKey.name ?? "", - stored.value, - stored.encryption, - apiKey.createdAt, - apiKey.expiresAt ?? "", - apiKey.limits ? JSON.stringify(apiKey.limits) : "" - ); - } - database.exec("COMMIT"); - secureDatabaseFilePermissions(this.dbFile); - return normalized; - } catch (error) { - try { - database.exec("ROLLBACK"); - } catch { - // Ignore rollback errors; the original write error is more useful. - } - secureDatabaseFilePermissions(this.dbFile); - throw error; - } - } - - private async getDatabase(): Promise { - if (this.database) { - return this.database; - } - - this.initPromise ??= this.open(); - return this.initPromise; - } - - private async open(): Promise { - const dbDir = dirname(this.dbFile); - mkdirSync(dbDir, { mode: privateDirMode, recursive: true }); - securePathPermissions(dbDir, privateDirMode); - const database = createBetterSqliteDatabase(this.dbFile); - configureSqliteDatabase(database); - - database.exec(` - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - encrypted_key TEXT NOT NULL, - encryption TEXT NOT NULL DEFAULT '${plainStorage}', - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL DEFAULT '', - limits_json TEXT NOT NULL DEFAULT '' - ); - CREATE INDEX IF NOT EXISTS api_keys_created_at_idx ON api_keys(created_at); - `); - - this.database = database; - secureDatabaseFilePermissions(this.dbFile); - return database; - } -} - -export const apiKeyStore = new ApiKeyStore(API_KEYS_DB_FILE); - -export async function loadPersistedApiKeys(): Promise { - if (!existsSync(API_KEYS_DB_FILE)) { - const legacyApiKeys = await readLegacyApiKeys(); - if (legacyApiKeys.length > 0) { - return legacyApiKeys; - } - } - return apiKeyStore.list(); -} - -export async function replacePersistedApiKeys(apiKeys: ApiKeyConfig[]): Promise { - return apiKeyStore.replace(apiKeys); -} - -async function readLegacyApiKeys(): Promise { - for (const dbFile of LEGACY_API_KEYS_DB_FILES) { - if (!existsSync(dbFile) || samePath(dbFile, API_KEYS_DB_FILE)) { - continue; - } - try { - const store = new ApiKeyStore(dbFile); - const apiKeys = await store.list(); - if (apiKeys.length > 0) { - return apiKeys; - } - } catch (error) { - console.warn(`[config] Failed to read legacy API key database ${dbFile}: ${formatError(error)}`); - } - } - return []; -} - -function toApiKeyConfig(row: Record): ApiKeyConfig | undefined { - const stored = toStoredApiKeyRow(row); - if (!stored) { - return undefined; - } - - const key = readStoredApiKey(stored.storedKey, stored.encryption); - if (!key) { - return undefined; - } - - const limits = parseApiKeyLimits(stored.limitsJson); - return { - createdAt: stored.createdAt, - ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), - id: stored.id, - key, - ...(limits ? { limits } : {}), - ...(stored.name ? { name: stored.name } : {}) - }; -} - -function toStoredApiKeyRow(row: Record): StoredApiKeyRow | undefined { - const id = readString(row.id); - const storedKey = readString(row.encrypted_key); - const createdAt = readString(row.created_at) || new Date(0).toISOString(); - if (!id || !storedKey) { - return undefined; - } - - return { - createdAt, - encryption: readString(row.encryption) || plainStorage, - expiresAt: readString(row.expires_at) || "", - id, - limitsJson: readString(row.limits_json) || "", - name: readString(row.name) || "", - storedKey - }; -} - -function storeApiKey(key: string): { encryption: string; value: string } { - return { - encryption: plainStorage, - value: key - }; -} - -function readStoredApiKey(value: string, encryption: string): string | undefined { - if (encryption !== plainStorage) { - console.warn(`[api-keys] Stored API key uses unsupported storage "${encryption}". Re-save the API key to migrate it.`); - return undefined; - } - return value.trim() || undefined; -} - -function secureDatabaseFilePermissions(file: string): void { - securePathPermissions(file, privateFileMode); - securePathPermissions(`${file}-wal`, privateFileMode); - securePathPermissions(`${file}-shm`, privateFileMode); -} - -function securePathPermissions(file: string, mode: number): void { - if (process.platform === "win32") { - return; - } - if (!existsSync(file)) { - return; - } - try { - chmodSync(file, mode); - } catch { - // Best effort for filesystems that do not support chmod. - } -} - -function parseApiKeyLimits(value: string): ApiKeyLimitConfig | undefined { - if (!value) { - return undefined; - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!isObject(parsed)) { - return undefined; - } - const limits: ApiKeyLimitConfig = {}; - for (const key of ["ipd", "iph", "ipm", "maxRequests", "maxTokens", "quotaWindowMs", "rpd", "rph", "rpm", "tpd", "tph", "tpm", "windowMs"] as const) { - const limit = readPositiveInteger(parsed[key]); - if (limit) { - limits[key] = limit; - } - } - return Object.keys(limits).length ? limits : undefined; - } catch { - return undefined; - } -} - -function configureSqliteDatabase(database: SqlDatabase): void { - database.pragma("journal_mode = WAL"); - database.pragma("synchronous = NORMAL"); - database.pragma("busy_timeout = 5000"); -} - -function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Array> { - return database.prepare(sql).all(...params) as Array>; -} - -function uniqueApiKeyConfigs(values: Array): ApiKeyConfig[] { - const seenKeys = new Set(); - const seenIds = new Set(); - const result: ApiKeyConfig[] = []; - for (const [index, value] of values.entries()) { - const key = value?.key.trim(); - if (!value || !key || seenKeys.has(key)) { - continue; - } - seenKeys.add(key); - const id = uniqueApiKeyId(value.id || `key-${index + 1}`, seenIds, index); - result.push({ - createdAt: value.createdAt || new Date(0).toISOString(), - ...(value.expiresAt ? { expiresAt: value.expiresAt } : {}), - id, - key, - ...(value.limits ? { limits: value.limits } : {}), - ...(value.name ? { name: value.name } : {}) - }); - } - return result; -} - -function uniqueApiKeyId(id: string, seenIds: Set, index: number): string { - const base = id.trim() || `key-${index + 1}`; - let candidate = base; - let suffix = 2; - while (seenIds.has(candidate)) { - candidate = `${base}-${suffix}`; - suffix += 1; - } - seenIds.add(candidate); - return candidate; -} - -function readPositiveInteger(value: unknown): number | undefined { - const number = Number(value); - return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function samePath(left: string, right: string): boolean { - return left.toLowerCase() === right.toLowerCase(); -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/core/src/config/app-config-store.ts b/packages/core/src/config/app-config-store.ts deleted file mode 100644 index 13e12095..00000000 --- a/packages/core/src/config/app-config-store.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { chmodSync, existsSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { APP_CONFIG_DB_FILE, LEGACY_APP_CONFIG_DB_FILES } from "@ccr/core/config/constants"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; - -type SqlDatabase = BetterSqliteDatabase; -type SqlValue = bigint | Buffer | number | string | null; - -const appConfigKey = "default"; -const privateDirMode = 0o700; -const privateFileMode = 0o600; - -class AppConfigStore { - private database?: SqlDatabase; - private initPromise?: Promise; - - constructor(private readonly dbFile: string) {} - - async read(): Promise { - return this.readKey(appConfigKey); - } - - async readKey(key: string): Promise { - const database = await this.getDatabase(); - const row = queryRows(database, "SELECT value_json FROM app_config WHERE key = ? LIMIT 1", [key])[0]; - const valueJson = readString(row?.value_json); - if (!valueJson) { - return undefined; - } - return JSON.parse(valueJson) as unknown; - } - - async replace(value: unknown): Promise { - await this.replaceKey(appConfigKey, value); - } - - async replaceKey(key: string, value: unknown): Promise { - const database = await this.getDatabase(); - database.prepare(` - INSERT INTO app_config (key, value_json, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET - value_json = excluded.value_json, - updated_at = excluded.updated_at - `).run(key, JSON.stringify(value), new Date().toISOString()); - secureDatabaseFilePermissions(this.dbFile); - } - - private async getDatabase(): Promise { - if (this.database) { - return this.database; - } - - this.initPromise ??= this.open(); - return this.initPromise; - } - - private async open(): Promise { - const dbDir = dirname(this.dbFile); - mkdirSync(dbDir, { mode: privateDirMode, recursive: true }); - securePathPermissions(dbDir, privateDirMode); - const database = createBetterSqliteDatabase(this.dbFile); - configureSqliteDatabase(database); - - database.exec(` - CREATE TABLE IF NOT EXISTS app_config ( - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - `); - - this.database = database; - secureDatabaseFilePermissions(this.dbFile); - return database; - } -} - -export const appConfigStore = new AppConfigStore(APP_CONFIG_DB_FILE); - -export async function loadPersistedAppConfig(): Promise { - if (!existsSync(APP_CONFIG_DB_FILE)) { - const legacyValue = await readLegacyAppConfigKey(appConfigKey); - if (legacyValue !== undefined) { - return legacyValue; - } - } - return appConfigStore.read(); -} - -export async function loadPersistedAppSetting(key: string): Promise { - if (!existsSync(APP_CONFIG_DB_FILE)) { - const legacyValue = await readLegacyAppConfigKey(key); - if (legacyValue !== undefined) { - return legacyValue; - } - } - return appConfigStore.readKey(key); -} - -export async function replacePersistedAppConfig(value: unknown): Promise { - await appConfigStore.replace(value); -} - -export async function replacePersistedAppSetting(key: string, value: unknown): Promise { - await appConfigStore.replaceKey(key, value); -} - -async function readLegacyAppConfigKey(key: string): Promise { - for (const dbFile of LEGACY_APP_CONFIG_DB_FILES) { - if (!existsSync(dbFile) || samePath(dbFile, APP_CONFIG_DB_FILE)) { - continue; - } - try { - const store = new AppConfigStore(dbFile); - const value = await store.readKey(key); - if (value !== undefined) { - return value; - } - } catch (error) { - console.warn(`[config] Failed to read legacy app config database ${dbFile}: ${formatError(error)}`); - } - } - return undefined; -} - -function configureSqliteDatabase(database: SqlDatabase): void { - database.pragma("journal_mode = WAL"); - database.pragma("synchronous = NORMAL"); - database.pragma("busy_timeout = 5000"); -} - -function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Array> { - return database.prepare(sql).all(...params) as Array>; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function secureDatabaseFilePermissions(file: string): void { - securePathPermissions(file, privateFileMode); - securePathPermissions(`${file}-wal`, privateFileMode); - securePathPermissions(`${file}-shm`, privateFileMode); -} - -function securePathPermissions(file: string, mode: number): void { - if (process.platform === "win32" || !existsSync(file)) { - return; - } - try { - chmodSync(file, mode); - } catch { - // Best effort for filesystems that do not support chmod. - } -} - -function samePath(left: string, right: string): boolean { - return left.toLowerCase() === right.toLowerCase(); -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/core/src/config/config-repository.ts b/packages/core/src/config/config-repository.ts new file mode 100644 index 00000000..7867c674 --- /dev/null +++ b/packages/core/src/config/config-repository.ts @@ -0,0 +1,842 @@ +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_APP_CONFIG_DB_FILES, + LEGACY_API_KEYS_DB_FILES, + LEGACY_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE, + ONBOARDING_FINISHED_AT_SETTING_KEY, + ONBOARDING_FINISHED_FILE +} from "@ccr/core/config/constants"; +import type { ApiKeyConfig, ApiKeyLimitConfig } from "@ccr/core/contracts/app"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; + +type SqlDatabase = BetterSqliteDatabase; +type SqlValue = bigint | Buffer | number | string | null; + +type StoredApiKeyRow = { + createdAt: string; + encryption: string; + expiresAt: string; + id: string; + limitsJson: string; + name: string; + storedKey: string; +}; + +const appConfigKey = "default"; +const plainStorage = "plain"; +const privateDirMode = 0o700; +const privateFileMode = 0o600; +const backupDedupMigrationId = "legacy-storage-backup-dedup-v1"; +const cleanupQueueBackfillMigrationId = "legacy-storage-cleanup-queue-v1"; +const unifiedConfigMigrationId = "unified-config-store-v1"; +const legacyJsonConfigFiles = [ + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE, + LEGACY_CONFIG_FILE +]; + +type ConfigRepositoryOptions = { + removeFile?: (file: string) => void; +}; + +export class ConfigRepository { + private database?: SqlDatabase; + private initPromise?: Promise; + private readonly removeFile: (file: string) => void; + + constructor( + private readonly dbFile: string, + options: ConfigRepositoryOptions = {} + ) { + this.removeFile = options.removeFile ?? ((file) => rmSync(file, { force: true })); + } + + async readAppConfig(): Promise { + return this.readSetting(appConfigKey); + } + + async readSetting(key: string): Promise { + const database = await this.getDatabase(); + const row = queryRows(database, "SELECT value_json FROM app_config WHERE key = ? LIMIT 1", [key])[0]; + const valueJson = readString(row?.value_json); + return valueJson ? JSON.parse(valueJson) as unknown : undefined; + } + + async replaceAppConfig(value: unknown): Promise { + const database = await this.getDatabase(); + replaceAppConfigRow(database, appConfigKey, value); + secureDatabaseFilePermissions(this.dbFile); + } + + async replaceSetting(key: string, value: unknown): Promise { + const database = await this.getDatabase(); + replaceAppConfigRow(database, key, value); + secureDatabaseFilePermissions(this.dbFile); + } + + async readRuntimeState(key: string): Promise { + const database = await this.getDatabase(); + const row = queryRows(database, "SELECT value_json FROM runtime_state WHERE key = ? LIMIT 1", [key])[0]; + const valueJson = readString(row?.value_json); + return valueJson ? JSON.parse(valueJson) as unknown : undefined; + } + + async replaceRuntimeState(key: string, value: unknown): Promise { + const database = await this.getDatabase(); + replaceJsonRow(database, "runtime_state", key, value); + secureDatabaseFilePermissions(this.dbFile); + } + + async deleteRuntimeState(key: string): Promise { + const database = await this.getDatabase(); + database.prepare("DELETE FROM runtime_state WHERE key = ?").run(key); + secureDatabaseFilePermissions(this.dbFile); + } + + async listApiKeys(): Promise { + const database = await this.getDatabase(); + return listApiKeys(database); + } + + async replaceApiKeys(apiKeys: ApiKeyConfig[]): Promise { + const normalized = uniqueApiKeyConfigs(apiKeys); + const database = await this.getDatabase(); + runTransaction(database, () => replaceApiKeyRows(database, normalized)); + secureDatabaseFilePermissions(this.dbFile); + return normalized; + } + + async replaceConfigSnapshot(value: unknown, apiKeys: ApiKeyConfig[]): Promise { + const normalized = uniqueApiKeyConfigs(apiKeys); + const database = await this.getDatabase(); + runTransaction(database, () => { + replaceAppConfigRow(database, appConfigKey, value); + replaceApiKeyRows(database, normalized); + }); + secureDatabaseFilePermissions(this.dbFile); + return normalized; + } + + async archiveLegacyJsonConfigFiles(files: string[]): Promise { + const requestedFiles = uniquePaths(files); + const unsupportedFiles = requestedFiles.filter( + (file) => !legacyJsonConfigFiles.some((candidate) => samePath(candidate, file)) + ); + if (unsupportedFiles.length > 0) { + throw new Error(`Refusing to archive unknown legacy JSON config file: ${unsupportedFiles.join(", ")}`); + } + if (requestedFiles.length === 0) { + return; + } + const database = await this.getDatabase(); + archiveAndRemoveFiles(database, requestedFiles, "legacy-json-config", this.removeFile); + } + + private async getDatabase(): Promise { + if (this.database) { + return this.database; + } + this.initPromise ??= this.open(); + return this.initPromise; + } + + private async open(): Promise { + const dbDir = dirname(this.dbFile); + mkdirSync(dbDir, { mode: privateDirMode, recursive: true }); + securePathPermissions(dbDir, privateDirMode); + const database = createBetterSqliteDatabase(this.dbFile); + configureSqliteDatabase(database); + createSchema(database); + + try { + migrateLegacySqliteStores(database, this.removeFile); + migrateLegacyOnboardingMarker(database, this.removeFile); + drainLegacyCleanup(database, this.removeFile, "legacy-json-config"); + } catch (error) { + database.close(); + throw error; + } + + this.database = database; + secureDatabaseFilePermissions(this.dbFile); + return database; + } +} + +export const configRepository = new ConfigRepository(APP_CONFIG_DB_FILE); + +export async function loadPersistedAppConfig(): Promise { + return configRepository.readAppConfig(); +} + +export async function loadPersistedAppSetting(key: string): Promise { + return configRepository.readSetting(key); +} + +export async function replacePersistedAppConfig(value: unknown): Promise { + await configRepository.replaceAppConfig(value); +} + +export async function replacePersistedAppSetting(key: string, value: unknown): Promise { + await configRepository.replaceSetting(key, value); +} + +export async function loadPersistedRuntimeState(key: string): Promise { + return configRepository.readRuntimeState(key); +} + +export async function replacePersistedRuntimeState(key: string, value: unknown): Promise { + await configRepository.replaceRuntimeState(key, value); +} + +export async function deletePersistedRuntimeState(key: string): Promise { + await configRepository.deleteRuntimeState(key); +} + +export async function loadPersistedApiKeys(): Promise { + return configRepository.listApiKeys(); +} + +export async function replacePersistedApiKeys(apiKeys: ApiKeyConfig[]): Promise { + return configRepository.replaceApiKeys(apiKeys); +} + +export async function replacePersistedConfigSnapshot(value: unknown, apiKeys: ApiKeyConfig[]): Promise { + return configRepository.replaceConfigSnapshot(value, apiKeys); +} + +export async function archiveLegacyJsonConfigFiles(files: string[]): Promise { + await configRepository.archiveLegacyJsonConfigFiles(files); +} + +function createSchema(database: SqlDatabase): void { + database.exec(` + CREATE TABLE IF NOT EXISTS app_config ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + encrypted_key TEXT NOT NULL, + encryption TEXT NOT NULL DEFAULT '${plainStorage}', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL DEFAULT '', + limits_json TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS api_keys_created_at_idx ON api_keys(created_at); + + CREATE TABLE IF NOT EXISTS config_schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS runtime_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS legacy_storage_backups ( + id TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_kind TEXT NOT NULL, + sha256 TEXT NOT NULL, + content BLOB NOT NULL, + archived_at TEXT NOT NULL + ); + + DELETE FROM legacy_storage_backups + WHERE NOT EXISTS ( + SELECT 1 + FROM config_schema_migrations + WHERE id = '${backupDedupMigrationId}' + ) + AND rowid NOT IN ( + SELECT MIN(rowid) + FROM legacy_storage_backups + GROUP BY source_path, source_kind, sha256 + ); + + CREATE UNIQUE INDEX IF NOT EXISTS legacy_storage_backups_content_idx + ON legacy_storage_backups(source_kind, source_path, sha256); + + INSERT OR IGNORE INTO config_schema_migrations (id, applied_at, details_json) + VALUES ('${backupDedupMigrationId}', '${new Date().toISOString()}', ''); + + CREATE TABLE IF NOT EXISTS legacy_storage_cleanup ( + source_path TEXT PRIMARY KEY, + source_kind TEXT NOT NULL, + sha256 TEXT NOT NULL, + queued_at TEXT NOT NULL + ); + + INSERT OR IGNORE INTO legacy_storage_cleanup ( + source_path, + source_kind, + sha256, + queued_at + ) + SELECT + source_path, + source_kind, + sha256, + archived_at + FROM legacy_storage_backups + WHERE NOT EXISTS ( + SELECT 1 + FROM config_schema_migrations + WHERE id = '${cleanupQueueBackfillMigrationId}' + ); + + INSERT OR IGNORE INTO config_schema_migrations (id, applied_at, details_json) + VALUES ('${cleanupQueueBackfillMigrationId}', '${new Date().toISOString()}', ''); + `); +} + +function migrateLegacySqliteStores(database: SqlDatabase, removeFile: (file: string) => void): void { + const legacyAppConfig = readLegacyAppConfigRows(); + const legacyApiKeys = readLegacyApiKeyRows(); + const appConfigRows = legacyAppConfig.rows; + const apiKeyRows = legacyApiKeys.rows; + const failedFiles = uniquePaths([...legacyAppConfig.failedFiles, ...legacyApiKeys.failedFiles]); + const readableFiles = uniquePaths([...legacyAppConfig.readableFiles, ...legacyApiKeys.readableFiles]) + .filter((file) => !failedFiles.some((failedFile) => samePath(file, failedFile))); + const backups = collectFileBackups(readableFiles.flatMap(sqliteDataFiles), "legacy-sqlite-config"); + + runTransaction(database, () => { + for (const row of appConfigRows) { + database.prepare(` + INSERT INTO app_config (key, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO NOTHING + `).run(row.key, row.valueJson, row.updatedAt); + } + insertRawApiKeyRows(database, apiKeyRows); + insertLegacyBackups(database, backups); + }); + + const cleanupFailures = drainLegacyCleanup(database, removeFile, "legacy-sqlite-config"); + const incompleteFiles = uniquePaths([...failedFiles, ...cleanupFailures]); + runTransaction(database, () => { + if (incompleteFiles.length > 0) { + database.prepare("DELETE FROM config_schema_migrations WHERE id = ?").run(unifiedConfigMigrationId); + return; + } + database.prepare(` + INSERT INTO config_schema_migrations (id, applied_at, details_json) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + applied_at = excluded.applied_at, + details_json = excluded.details_json + `).run( + unifiedConfigMigrationId, + new Date().toISOString(), + JSON.stringify({ + appConfigRows: appConfigRows.length, + apiKeyRows: apiKeyRows.length, + archivedFiles: backups.length + }) + ); + }); + + if (incompleteFiles.length > 0) { + console.warn( + `[config] Preserving legacy SQLite source${incompleteFiles.length === 1 ? "" : "s"} for a future migration retry: ${incompleteFiles.join(", ")}` + ); + } +} + +function migrateLegacyOnboardingMarker(database: SqlDatabase, removeFile: (file: string) => void): void { + if (!existsSync(ONBOARDING_FINISHED_FILE)) { + drainLegacyCleanup(database, removeFile, "legacy-onboarding-state"); + return; + } + const backup = collectFileBackups([ONBOARDING_FINISHED_FILE], "legacy-onboarding-state")[0]; + if (!backup) { + return; + } + const existing = queryRows( + database, + "SELECT key FROM app_config WHERE key = ? LIMIT 1", + [ONBOARDING_FINISHED_AT_SETTING_KEY] + ).length > 0; + + runTransaction(database, () => { + if (!existing) { + const finishedAt = statSync(ONBOARDING_FINISHED_FILE).mtime.toISOString(); + replaceAppConfigRow(database, ONBOARDING_FINISHED_AT_SETTING_KEY, finishedAt); + } + insertLegacyBackups(database, [backup]); + }); + drainLegacyCleanup(database, removeFile, "legacy-onboarding-state"); +} + +function archiveAndRemoveFiles( + database: SqlDatabase, + files: string[], + sourceKind: string, + removeFile: (file: string) => void +): void { + const backups = collectFileBackups(uniquePaths(files), sourceKind); + if (backups.length > 0) { + runTransaction(database, () => insertLegacyBackups(database, backups)); + } + drainLegacyCleanup(database, removeFile, sourceKind); +} + +type LegacyBackup = { + content: Buffer; + id: string; + sha256: string; + sourceKind: string; + sourcePath: string; +}; + +function collectFileBackups(files: string[], sourceKind: string): LegacyBackup[] { + return files.flatMap((file): LegacyBackup[] => { + if (!existsSync(file) || samePath(file, APP_CONFIG_DB_FILE)) { + return []; + } + const content = readFileSync(file); + const sha256 = createHash("sha256").update(content).digest("hex"); + return [{ + content, + id: createHash("sha256").update(`${sourceKind}\0${resolve(file)}\0${sha256}`).digest("hex"), + sha256, + sourceKind, + sourcePath: resolve(file) + }]; + }); +} + +function insertLegacyBackups(database: SqlDatabase, backups: LegacyBackup[]): void { + const archivedAt = new Date().toISOString(); + const backupStatement = database.prepare(` + INSERT OR IGNORE INTO legacy_storage_backups ( + id, source_path, source_kind, sha256, content, archived_at + ) VALUES (?, ?, ?, ?, ?, ?) + `); + const cleanupStatement = database.prepare(` + INSERT INTO legacy_storage_cleanup ( + source_path, source_kind, sha256, queued_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(source_path) DO UPDATE SET + source_kind = excluded.source_kind, + sha256 = excluded.sha256, + queued_at = excluded.queued_at + `); + for (const backup of backups) { + backupStatement.run( + backup.id, + backup.sourcePath, + backup.sourceKind, + backup.sha256, + backup.content, + archivedAt + ); + cleanupStatement.run( + backup.sourcePath, + backup.sourceKind, + backup.sha256, + archivedAt + ); + } +} + +function drainLegacyCleanup( + database: SqlDatabase, + removeFile: (file: string) => void, + sourceKind: string +): string[] { + const rows = queryRows( + database, + ` + SELECT source_path, sha256 + FROM legacy_storage_cleanup + WHERE source_kind = ? + ORDER BY queued_at, source_path + `, + [sourceKind] + ); + const failedFiles: string[] = []; + for (const row of rows) { + const file = readString(row.source_path); + const archivedSha256 = readString(row.sha256); + if (!file || !archivedSha256) { + continue; + } + if (!existsSync(file)) { + deleteLegacyCleanupRow(database, file); + continue; + } + try { + const currentSha256 = createHash("sha256").update(readFileSync(file)).digest("hex"); + if (currentSha256 !== archivedSha256) { + const matchingBackup = queryRows( + database, + ` + SELECT id + FROM legacy_storage_backups + WHERE source_path = ? AND sha256 = ? + LIMIT 1 + `, + [file, currentSha256] + )[0]; + if (!matchingBackup) { + failedFiles.push(file); + console.warn(`[config] Preserving changed legacy config source for a future migration retry: ${file}`); + continue; + } + database.prepare(` + UPDATE legacy_storage_cleanup + SET sha256 = ?, queued_at = ? + WHERE source_path = ? + `).run(currentSha256, new Date().toISOString(), file); + } + removeFile(file); + if (existsSync(file)) { + throw new Error("source still exists after cleanup"); + } + deleteLegacyCleanupRow(database, file); + } catch (error) { + failedFiles.push(file); + console.warn(`[config] Failed to remove migrated config source ${file}: ${formatError(error)}`); + } + } + return failedFiles; +} + +function deleteLegacyCleanupRow(database: SqlDatabase, file: string): void { + database.prepare("DELETE FROM legacy_storage_cleanup WHERE source_path = ?").run(file); +} + +type LegacyAppConfigRow = { + key: string; + updatedAt: string; + valueJson: string; +}; + +type LegacySqliteReadResult = { + failedFiles: string[]; + readableFiles: string[]; + rows: Row[]; +}; + +function readLegacyAppConfigRows(): LegacySqliteReadResult { + const result: LegacySqliteReadResult = { + failedFiles: [], + readableFiles: [], + rows: [] + }; + for (const dbFile of LEGACY_APP_CONFIG_DB_FILES) { + if (!existsSync(dbFile) || samePath(dbFile, APP_CONFIG_DB_FILE)) { + continue; + } + let database: SqlDatabase | undefined; + try { + database = createBetterSqliteDatabase(dbFile, { fileMustExist: true, readonly: true }); + const rows = queryRows(database, "SELECT key, value_json, updated_at FROM app_config"); + result.readableFiles.push(dbFile); + result.rows.push(...rows.flatMap((row): LegacyAppConfigRow[] => { + const key = readString(row.key); + const valueJson = readString(row.value_json); + if (!key || !valueJson) { + return []; + } + return [{ + key, + updatedAt: readString(row.updated_at) || new Date(0).toISOString(), + valueJson + }]; + })); + } catch (error) { + result.failedFiles.push(dbFile); + console.warn(`[config] Failed to read legacy app config database ${dbFile}: ${formatError(error)}`); + } finally { + database?.close(); + } + } + return result; +} + +function readLegacyApiKeyRows(): LegacySqliteReadResult> { + const result: LegacySqliteReadResult> = { + failedFiles: [], + readableFiles: [], + rows: [] + }; + for (const dbFile of LEGACY_API_KEYS_DB_FILES) { + if (!existsSync(dbFile) || samePath(dbFile, APP_CONFIG_DB_FILE)) { + continue; + } + let database: SqlDatabase | undefined; + try { + database = createBetterSqliteDatabase(dbFile, { fileMustExist: true, readonly: true }); + const rows = queryRows(database, ` + SELECT id, name, encrypted_key, encryption, created_at, expires_at, limits_json + FROM api_keys + ORDER BY rowid + `); + result.readableFiles.push(dbFile); + result.rows.push(...rows); + } catch (error) { + result.failedFiles.push(dbFile); + console.warn(`[config] Failed to read legacy API key database ${dbFile}: ${formatError(error)}`); + } finally { + database?.close(); + } + } + return result; +} + +function insertRawApiKeyRows(database: SqlDatabase, rows: Array>): void { + const statement = database.prepare(` + INSERT OR IGNORE INTO api_keys ( + id, name, encrypted_key, encryption, created_at, expires_at, limits_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + const stored = toStoredApiKeyRow(row); + if (!stored) { + continue; + } + statement.run( + stored.id, + stored.name, + stored.storedKey, + stored.encryption, + stored.createdAt, + stored.expiresAt, + stored.limitsJson + ); + } +} + +function replaceAppConfigRow(database: SqlDatabase, key: string, value: unknown): void { + replaceJsonRow(database, "app_config", key, value); +} + +function replaceJsonRow(database: SqlDatabase, table: "app_config" | "runtime_state", key: string, value: unknown): void { + database.prepare(` + INSERT INTO ${table} (key, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + `).run(key, JSON.stringify(value), new Date().toISOString()); +} + +function listApiKeys(database: SqlDatabase): ApiKeyConfig[] { + const rows = queryRows(database, ` + SELECT id, name, encrypted_key, encryption, created_at, expires_at, limits_json + FROM api_keys + ORDER BY rowid + `); + return uniqueApiKeyConfigs(rows.map(toApiKeyConfig)); +} + +function replaceApiKeyRows(database: SqlDatabase, apiKeys: ApiKeyConfig[]): void { + const statement = database.prepare(` + INSERT INTO api_keys ( + id, name, encrypted_key, encryption, created_at, expires_at, limits_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + database.exec("DELETE FROM api_keys"); + for (const apiKey of apiKeys) { + statement.run( + apiKey.id, + apiKey.name ?? "", + apiKey.key, + plainStorage, + apiKey.createdAt, + apiKey.expiresAt ?? "", + apiKey.limits ? JSON.stringify(apiKey.limits) : "" + ); + } +} + +function toApiKeyConfig(row: Record): ApiKeyConfig | undefined { + const stored = toStoredApiKeyRow(row); + if (!stored) { + return undefined; + } + if (stored.encryption !== plainStorage) { + console.warn(`[api-keys] Stored API key uses unsupported storage "${stored.encryption}". Re-save the API key to migrate it.`); + return undefined; + } + const key = stored.storedKey.trim(); + if (!key) { + return undefined; + } + const limits = parseApiKeyLimits(stored.limitsJson); + return { + createdAt: stored.createdAt, + ...(stored.expiresAt ? { expiresAt: stored.expiresAt } : {}), + id: stored.id, + key, + ...(limits ? { limits } : {}), + ...(stored.name ? { name: stored.name } : {}) + }; +} + +function toStoredApiKeyRow(row: Record): StoredApiKeyRow | undefined { + const id = readString(row.id); + const storedKey = readString(row.encrypted_key); + if (!id || !storedKey) { + return undefined; + } + return { + createdAt: readString(row.created_at) || new Date(0).toISOString(), + encryption: readString(row.encryption) || plainStorage, + expiresAt: readString(row.expires_at) || "", + id, + limitsJson: readString(row.limits_json) || "", + name: readString(row.name) || "", + storedKey + }; +} + +function parseApiKeyLimits(value: string): ApiKeyLimitConfig | undefined { + if (!value) { + return undefined; + } + try { + const parsed = JSON.parse(value) as unknown; + if (!isObject(parsed)) { + return undefined; + } + const limits: ApiKeyLimitConfig = {}; + for (const key of ["ipd", "iph", "ipm", "maxRequests", "maxTokens", "quotaWindowMs", "rpd", "rph", "rpm", "tpd", "tph", "tpm", "windowMs"] as const) { + const limit = readPositiveInteger(parsed[key]); + if (limit) { + limits[key] = limit; + } + } + return Object.keys(limits).length ? limits : undefined; + } catch { + return undefined; + } +} + +function uniqueApiKeyConfigs(values: Array): ApiKeyConfig[] { + const seenKeys = new Set(); + const seenIds = new Set(); + const result: ApiKeyConfig[] = []; + for (const [index, value] of values.entries()) { + const key = value?.key.trim(); + if (!value || !key || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + const id = uniqueApiKeyId(value.id || `key-${index + 1}`, seenIds, index); + result.push({ + createdAt: value.createdAt || new Date(0).toISOString(), + ...(value.expiresAt ? { expiresAt: value.expiresAt } : {}), + id, + key, + ...(value.limits ? { limits: value.limits } : {}), + ...(value.name ? { name: value.name } : {}) + }); + } + return result; +} + +function uniqueApiKeyId(id: string, seenIds: Set, index: number): string { + const base = id.trim() || `key-${index + 1}`; + let candidate = base; + let suffix = 2; + while (seenIds.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + seenIds.add(candidate); + return candidate; +} + +function runTransaction(database: SqlDatabase, operation: () => void): void { + try { + database.exec("BEGIN IMMEDIATE"); + operation(); + database.exec("COMMIT"); + } catch (error) { + try { + database.exec("ROLLBACK"); + } catch { + // Preserve the original transaction error. + } + throw error; + } +} + +function sqliteDataFiles(file: string): string[] { + return [file, `${file}-wal`, `${file}-shm`]; +} + +function configureSqliteDatabase(database: SqlDatabase): void { + database.pragma("journal_mode = WAL"); + database.pragma("synchronous = NORMAL"); + database.pragma("busy_timeout = 5000"); +} + +function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Array> { + return database.prepare(sql).all(...params) as Array>; +} + +function readPositiveInteger(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function secureDatabaseFilePermissions(file: string): void { + securePathPermissions(file, privateFileMode); + securePathPermissions(`${file}-wal`, privateFileMode); + securePathPermissions(`${file}-shm`, privateFileMode); +} + +function securePathPermissions(file: string, mode: number): void { + if (process.platform === "win32" || !existsSync(file)) { + return; + } + try { + chmodSync(file, mode); + } catch { + // Best effort for filesystems that do not support chmod. + } +} + +function uniquePaths(files: string[]): string[] { + const seen = new Set(); + return files.filter((file) => { + const normalized = resolve(file).toLowerCase(); + if (seen.has(normalized)) { + return false; + } + seen.add(normalized); + return true; + }); +} + +function samePath(left: string, right: string): boolean { + return resolve(left).toLowerCase() === resolve(right).toLowerCase(); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index edd14fa8..15b52ca2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,13 +1,19 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; -import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store"; -import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; -import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants"; +import { + archiveLegacyJsonConfigFiles, + loadPersistedApiKeys, + loadPersistedAppConfig, + replacePersistedApiKeys, + replacePersistedAppConfig, + replacePersistedConfigSnapshot +} from "@ccr/core/config/config-repository"; +import { LEGACY_ACTIVE_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants"; import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex"; import { normalizeGrokProviderAccountConfig, normalizeGrokProviderMediaCapabilities } from "@ccr/core/agents/local-providers/grok"; import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode"; -import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app"; +import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, DEFAULT_TRAY_COMPONENT_VARIANTS, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app"; import { createDefaultAppConfig } from "@ccr/core/config/default-config"; import { maxRequestLogBodyBytes } from "@ccr/core/observability/request-log-limits"; import { findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; @@ -32,7 +38,6 @@ import type { GatewayProviderCapability, GatewayProviderCapabilityProtocol, GatewayProviderConfig, - GatewayProviderProtocol, MediaToolsConfig, ObservabilityConfig, OverviewMetricKind, @@ -99,10 +104,16 @@ type LoadedAppConfig = Partial; }; +type LegacyJsonConfigLoadResult = { + file: string; + value: Partial; +}; + const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ "legacy-subagent", "legacy-background", @@ -115,8 +126,7 @@ const GENERATED_GATEWAY_API_KEY_ID = "local-gateway"; const GATEWAY_PLUGIN_PERMISSION_ID_SET = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({ - coreHost: INTERNAL_GATEWAY_CORE_HOST, - generatedConfigFile: GATEWAY_CONFIG_FILE + coreHost: INTERNAL_GATEWAY_CORE_HOST }); function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig { @@ -280,7 +290,6 @@ export async function loadAppConfig(): Promise { ...gatewayConfig, coreHost: INTERNAL_GATEWAY_CORE_HOST, corePort, - generatedConfigFile: GATEWAY_CONFIG_FILE, host: gatewayConfig.host ?? host, port: gatewayConfig.port ?? port }, @@ -329,11 +338,13 @@ export async function loadAppConfig(): Promise { const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; const shouldRepairProviderCapabilities = hasUnsupportedNvidiaCapabilities(value.Providers); const shouldRepairKnownPlugins = pluginMigration.changed; - if (shouldPersistApiKeys) { - await replacePersistedApiKeys(apiKeys); - } if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys || shouldRepairProviderCapabilities || shouldRepairKnownPlugins) { - await writeSanitizedConfig(config); + await replacePersistedConfigSnapshot(sanitizeConfigForDisk(config), apiKeys); + } + if (loadedRawConfig.source === "legacy-json" && loadedRawConfig.legacyJsonFile) { + await archiveLegacyJsonConfigFiles([loadedRawConfig.legacyJsonFile]).catch((archiveError) => { + console.warn(`[config] Failed to archive legacy JSON config: ${formatError(archiveError)}`); + }); } return config; } catch (error) { @@ -381,14 +392,13 @@ async function saveAppConfigNow(config: AppConfig): Promise { assertProviderApiKeysAreSafe(normalizedConfig); const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); const pluginMigration = migrateKnownGatewayPluginConfigs(normalizedConfig.plugins); - await replacePersistedApiKeys(apiKeys); - await writeSanitizedConfig({ + await replacePersistedConfigSnapshot(sanitizeConfigForDisk({ ...normalizedConfig, theme: appThemePreferenceOverride ?? normalizedConfig.theme, APIKEY: apiKeys[0]?.key ?? "", APIKEYS: apiKeys, plugins: pluginMigration.plugins - }); + }), apiKeys); return loadAppConfig(); } @@ -593,8 +603,9 @@ async function loadRawAppConfig(): Promise { const legacyConfig = readLegacyJsonConfig(); if (legacyConfig) { return { + legacyJsonFile: legacyConfig.file, source: "legacy-json", - value: legacyConfig + value: legacyConfig.value }; } @@ -604,8 +615,8 @@ async function loadRawAppConfig(): Promise { }; } -function readLegacyJsonConfig(): Partial | undefined { - const files = uniqueStrings([CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE, LEGACY_CONFIG_FILE]); +function readLegacyJsonConfig(): LegacyJsonConfigLoadResult | undefined { + const files = uniqueStrings([LEGACY_ACTIVE_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE, LEGACY_CONFIG_FILE]); for (const file of files) { if (!existsSync(file)) { continue; @@ -613,7 +624,10 @@ function readLegacyJsonConfig(): Partial | undefined { try { const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; if (isObject(parsed)) { - return parsed as Partial; + return { + file, + value: parsed as Partial + }; } console.warn(`[config] Ignoring legacy config with non-object root: ${file}`); } catch (error) { diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index 479a7d1f..3c216a37 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -10,19 +10,22 @@ export { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR }; export const CONFIGDIR = resolveRuntimeConfigDir(); export const LEGACY_WINDOWS_CONFIGDIR = path.join(resolveRuntimeAppPath("appData"), APP_NAME); export const LEGACY_WINDOWS_CONFIG_FILE = path.join(LEGACY_WINDOWS_CONFIGDIR, "config.json"); -export const CONFIG_FILE = path.join(CONFIGDIR, "config.json"); +export const LEGACY_ACTIVE_CONFIG_FILE = path.join(CONFIGDIR, "config.json"); export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished"); +export const ONBOARDING_FINISHED_AT_SETTING_KEY = "onboardingFinishedAt"; export const DATADIR = resolveRuntimeDataDir(); export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite"); -export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite"); +export const LEGACY_API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite"); export const LEGACY_APP_CONFIG_DB_FILES = process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "config.sqlite")] : []; -export const LEGACY_API_KEYS_DB_FILES = process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "api-keys.sqlite")] : []; +export const LEGACY_API_KEYS_DB_FILES = [ + LEGACY_API_KEYS_DB_FILE, + ...(process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "api-keys.sqlite")] : []) +]; export const CERTDIR = path.join(DATADIR, "certs"); export const PROVIDER_ICON_CACHE_DIR = path.join(DATADIR, "provider-icons"); export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem"); export const PROXY_CA_CERT_DER_FILE = path.join(CERTDIR, "ca.cer"); export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem"); -export const GATEWAY_CONFIG_FILE = path.join(CONFIGDIR, "gateway.config.json"); export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite"); export const CONTEXT_ARCHIVE_DB_FILE = path.join(DATADIR, "context-archive.sqlite"); export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool"); diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 86e93a60..f2097d36 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -20,10 +20,9 @@ export const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [ export type DefaultAppConfigOptions = { coreHost?: string; - generatedConfigFile: string; }; -export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppConfig { +export function createDefaultAppConfig(options: DefaultAppConfigOptions = {}): AppConfig { const coreHost = options.coreHost ?? "127.0.0.1"; return { APIKEY: "", @@ -106,7 +105,6 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon coreHost, corePort: 3457, enabled: true, - generatedConfigFile: options.generatedConfigFile, host: "127.0.0.1", port: 3456 }, diff --git a/packages/core/src/config/onboarding-state.ts b/packages/core/src/config/onboarding-state.ts new file mode 100644 index 00000000..ddd3d81e --- /dev/null +++ b/packages/core/src/config/onboarding-state.ts @@ -0,0 +1,27 @@ +import { existsSync } from "node:fs"; +import { + loadPersistedAppSetting, + replacePersistedAppSetting +} from "@ccr/core/config/config-repository"; +import { + ONBOARDING_FINISHED_AT_SETTING_KEY, + ONBOARDING_FINISHED_FILE +} from "@ccr/core/config/constants"; + +export async function loadOnboardingFinished(): Promise { + try { + const persisted = await loadPersistedAppSetting(ONBOARDING_FINISHED_AT_SETTING_KEY); + return typeof persisted === "string" && Boolean(persisted.trim()); + } catch (error) { + console.warn(`[config] Failed to load onboarding state: ${formatError(error)}`); + return existsSync(ONBOARDING_FINISHED_FILE); + } +} + +export async function markOnboardingFinished(): Promise { + await replacePersistedAppSetting(ONBOARDING_FINISHED_AT_SETTING_KEY, new Date().toISOString()); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 8102c595..c026ce34 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1,12 +1,9 @@ export type AppInfo = { - appConfigDbFile: string; - apiKeysDbFile: string; chatgptAppPath?: string; + configDbFile: string; configDir: string; - configFile: string; dataDir: string; desktop: boolean; - gatewayConfigFile: string; launchAtLoginSupported: boolean; requestLogsDbFile: string; name: string; @@ -729,7 +726,6 @@ export type GatewayRuntimeConfig = { coreHost: string; corePort: number; enabled: boolean; - generatedConfigFile: string; host: string; port: number; }; @@ -1778,7 +1774,6 @@ export type GatewayStatus = { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; - generatedConfigFile: string; lastError?: string; lastStartedAt?: string; networkEndpoints: GatewayNetworkEndpoint[]; diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index f510edbc..ac8b8ecc 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -11,11 +11,11 @@ import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch import { pluginService } from "@ccr/core/plugins/service"; import { proxyService } from "@ccr/core/proxy/service"; import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; -import { writeCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-writer"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; import { closeServer, formatError } from "@ccr/core/gateway/http/io"; import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync"; import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync"; -import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor"; +import { assertLoopbackCoreHost, endpoint, gatewayNetworkEndpoints, generateCoreGatewayAuthToken, isCoreGatewayHealthy, loopbackCoreHostError, removeManagedCoreGatewayMarker, shouldRunGatewayRuntime, shouldRunUnifiedServer, spawnGatewayProcess, stopPreviousManagedCoreGateway, waitForManagedCoreGatewayReady, writeManagedCoreGatewayMarker } from "@ccr/core/gateway/core-runtime/supervisor"; import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared"; import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared"; import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline"; @@ -71,7 +71,6 @@ class GatewayService { private status: GatewayStatus = { coreEndpoint: "", endpoint: "", - generatedConfigFile: "", networkEndpoints: [], state: "stopped" }; @@ -105,7 +104,6 @@ class GatewayService { this.status = { coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), endpoint: endpoint(config.gateway.host, config.gateway.port), - generatedConfigFile: config.gateway.generatedConfigFile, networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port), state: "starting" }; @@ -146,7 +144,7 @@ class GatewayService { if (shouldRunGateway) { await proxyService.refreshUpstreamProxyFromCurrentSystem(); const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https", config); - await writeCoreGatewayConfig( + const coreGatewayConfig = await compileCoreGatewayConfig( config, this.rawTraceSynchronizer.token, this.billingSynchronizer.token, @@ -154,20 +152,32 @@ class GatewayService { this.browserWebSearchMcpIntegration, upstreamProxyUrl ); - await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint); + await stopPreviousManagedCoreGateway(this.status.coreEndpoint); if (await isCoreGatewayHealthy(this.status.coreEndpoint)) { throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`); } const runtimeId = randomUUID(); - this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, coreAuthToken); + const spawnedGateway = spawnGatewayProcess(config, coreGatewayConfig, upstreamProxyUrl, runtimeId, coreAuthToken); + this.child = spawnedGateway.child; this.coreAuthToken = coreAuthToken; const managedChild = this.child; - writeManagedCoreGatewayMarker(config, this.child, runtimeId); + let markerWritePromise: Promise | undefined; + let startupFailure: Error | undefined; this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`)); this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`)); - this.child.on("exit", (code, signal) => { - void this.handleCoreGatewayExit(managedChild, code, signal); + this.child.once("error", (error) => { + startupFailure ??= new Error(`Core gateway failed to start: ${formatError(error)}`); + void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message); }); + this.child.once("exit", (code, signal) => { + startupFailure ??= new Error(coreGatewayExitMessage(code, signal)); + void this.handleCoreGatewayTermination(managedChild, markerWritePromise, startupFailure.message); + }); + markerWritePromise = writeManagedCoreGatewayMarker(managedChild, runtimeId); + await Promise.all([markerWritePromise, spawnedGateway.configAccepted]); + assertManagedGatewayStartupContinues(managedChild, startupFailure); + await waitForManagedCoreGatewayReady(this.status.coreEndpoint, runtimeId, managedChild); + assertManagedGatewayStartupContinues(managedChild, startupFailure); } this.status = { @@ -191,13 +201,14 @@ class GatewayService { async stop(options: GatewayStopOptions = {}): Promise { const child = this.child; - const config = this.config; this.child = undefined; this.coreAuthToken = ""; if (child && !child.killed) { child.kill(); } - removeManagedCoreGatewayMarker(config); + if (child) { + await removeManagedCoreGatewayMarkerBestEffort(); + } const server = this.server; this.server = undefined; @@ -255,7 +266,6 @@ class GatewayService { ...this.status, coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), endpoint: endpoint(config.gateway.host, config.gateway.port), - generatedConfigFile: config.gateway.generatedConfigFile, networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port) }; } @@ -344,15 +354,22 @@ class GatewayService { }); } - private async handleCoreGatewayExit(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): Promise { + private async handleCoreGatewayTermination( + child: ChildProcess, + markerWritePromise: Promise | undefined, + message: string + ): Promise { + await markerWritePromise?.catch(() => undefined); if (this.child !== child || this.status.state === "stopped") { return; } - removeManagedCoreGatewayMarker(this.config); + this.child = undefined; + this.coreAuthToken = ""; + await removeManagedCoreGatewayMarkerBestEffort(); this.status = { ...this.status, coreManagedExternally: undefined, - lastError: `Core gateway exited with ${signal ?? code ?? "unknown status"}`, + lastError: message, pid: undefined, state: "error" }; @@ -368,5 +385,21 @@ class GatewayService { } +function coreGatewayExitMessage(code: number | null, signal: NodeJS.Signals | null): string { + return `Core gateway exited with ${signal ?? code ?? "unknown status"}`; +} + +function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void { + if (startupFailure || child.exitCode !== null || child.signalCode !== null || child.killed) { + throw startupFailure ?? new Error(coreGatewayExitMessage(child.exitCode, child.signalCode)); + } +} + +async function removeManagedCoreGatewayMarkerBestEffort(): Promise { + await removeManagedCoreGatewayMarker().catch((error) => { + console.warn(`[gateway] Failed to remove gateway runtime marker: ${formatError(error)}`); + }); +} + export const gatewayService = new GatewayService(); diff --git a/packages/core/src/gateway/auth/api-key-authorizer.ts b/packages/core/src/gateway/auth/api-key-authorizer.ts index 8fc67105..fb1d2692 100644 --- a/packages/core/src/gateway/auth/api-key-authorizer.ts +++ b/packages/core/src/gateway/auth/api-key-authorizer.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; -import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { loadPersistedApiKeys } from "@ccr/core/config/config-repository"; import { formatError, readAuthToken, readRemoteControlQueryAuthToken, sendJson } from "@ccr/core/gateway/http/io"; import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter"; import type { ApiKeyAuthorizationResult, ApiKeyLimitRule, ApiKeyLimitUsage } from "@ccr/core/gateway/internal/shared"; diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index afb3f194..f2ff64ec 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -1,7 +1,6 @@ /** * Extracted from gateway/service.ts. Keep this module focused on its named gateway boundary. */ -import { join as pathJoin } from "node:path"; import { isGatewayProviderEnabled } from "@ccr/core/contracts/app"; import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app"; import { codexDefaultBaseUrl, kimiAccessTokenExpired, kimiIdentityHeaders, readClaudeCodeOauth, readCodexAuth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service"; @@ -14,7 +13,7 @@ import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config"; import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery"; import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology"; import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync"; -import { endpoint, resolveUndiciProxyAgentModule, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor"; +import { endpoint, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor"; import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared"; import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; @@ -54,7 +53,7 @@ export async function compileCoreGatewayConfig( const providerPluginsWithCapabilityAliases = withProviderCapabilityPluginAliases(providerPlugins, enabledProviders); const virtualModelProfiles = coreGatewayVirtualModelProfiles(config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; + const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined; const proxyEnv = upstreamProxyUrl ? { CCR_UPSTREAM_PROXY_URL: upstreamProxyUrl, CCR_UNDICI_MODULE: resolveUndiciProxyAgentModule() } : undefined; @@ -136,7 +135,7 @@ export async function compileCoreGatewayConfig( { enabled: true, key: upstreamHeaderSanitizerPluginKey, - modulePath: pathJoin(__dirname, "upstream-header-sanitizer.js") + modulePath: resolveUpstreamHeaderSanitizerEntry() } ], upstreamTimeoutMs: Number(config.API_TIMEOUT_MS) || 0, @@ -151,7 +150,6 @@ export async function compileCoreGatewayConfig( }; } - function withProviderCapabilityPluginAliases( providerPlugins: unknown[], providers: GatewayProviderConfig[] diff --git a/packages/core/src/gateway/core-runtime/config-writer.ts b/packages/core/src/gateway/core-runtime/config-writer.ts deleted file mode 100644 index acf7cb4b..00000000 --- a/packages/core/src/gateway/core-runtime/config-writer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import type { AppConfig } from "@ccr/core/contracts/app"; -import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; -import { assertLoopbackCoreHost } from "@ccr/core/gateway/core-runtime/supervisor"; -import { - privateDirMode, - privateFileMode, - type BrowserWebSearchMcpIntegration -} from "@ccr/core/gateway/internal/shared"; - -export async function writeCoreGatewayConfig( - config: AppConfig, - rawTraceSyncToken: string, - billingUsageSyncToken: string, - coreAuthToken: string, - browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration, - upstreamProxyUrl?: string -): Promise { - assertLoopbackCoreHost(config.gateway.coreHost); - mkdirSync(dirname(config.gateway.generatedConfigFile), { - mode: privateDirMode, - recursive: true - }); - - const payload = await compileCoreGatewayConfig( - config, - rawTraceSyncToken, - billingUsageSyncToken, - coreAuthToken, - browserWebSearchMcpIntegration, - upstreamProxyUrl - ); - writePrivateTextFile( - config.gateway.generatedConfigFile, - `${JSON.stringify(payload, null, 2)}\n` - ); -} - -function writePrivateTextFile(file: string, content: string): void { - writeFileSync(file, content, { encoding: "utf8", mode: privateFileMode }); - if (process.platform !== "win32") { - try { - chmodSync(file, privateFileMode); - } catch { - // Best effort for filesystems that do not support chmod. - } - } -} diff --git a/packages/core/src/gateway/core-runtime/gateway-bootstrap.ts b/packages/core/src/gateway/core-runtime/gateway-bootstrap.ts new file mode 100644 index 00000000..f3eacd78 --- /dev/null +++ b/packages/core/src/gateway/core-runtime/gateway-bootstrap.ts @@ -0,0 +1,115 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + +type GatewayStartMessage = { + config: Record; + gatewayEntry: string; + protocolVersion: 1; + type: "gateway:start"; +}; + +type MutableFs = { + existsSync: (file: unknown) => boolean; + readFileSync: (file: unknown, options?: unknown) => unknown; + renameSync: (oldPath: unknown, newPath: unknown) => void; + writeFileSync: (file: unknown, data: unknown, options?: unknown) => void; +}; + +const requireFromHere = createRequire(__filename); +const virtualConfigPath = path.resolve(process.cwd(), `.ccr-gateway-config-${process.pid}`); +let started = false; + +process.once("disconnect", () => { + process.exit(0); +}); + +process.on("message", (message: unknown) => { + if (started) { + return; + } + try { + const start = parseStartMessage(message); + started = true; + installVirtualConfigFile(start.config); + process.env.GATEWAY_CONFIG_PATH = virtualConfigPath; + requireFromHere(start.gatewayEntry); + process.send?.({ protocolVersion: 1, type: "gateway:config-accepted" }); + } catch (error) { + console.error(`[gateway-bootstrap] ${formatError(error)}`); + process.exit(1); + } +}); + +function installVirtualConfigFile(config: Record): void { + const fs = requireFromHere("node:fs") as MutableFs; + const content = `${JSON.stringify(config)}\n`; + const originalExistsSync = fs.existsSync.bind(fs); + const originalReadFileSync = fs.readFileSync.bind(fs); + const originalRenameSync = fs.renameSync.bind(fs); + const originalWriteFileSync = fs.writeFileSync.bind(fs); + + fs.existsSync = (file) => isVirtualConfigPath(file) || originalExistsSync(file); + fs.readFileSync = (file, options) => { + if (!isVirtualConfigPath(file)) { + return originalReadFileSync(file, options); + } + return readsText(options) ? content : Buffer.from(content, "utf8"); + }; + fs.writeFileSync = (file, data, options) => { + if (isManagedConfigPath(file)) { + throw new Error("Gateway configuration is managed by Claude Code Router."); + } + originalWriteFileSync(file, data, options); + }; + fs.renameSync = (oldPath, newPath) => { + if (isManagedConfigPath(oldPath) || isManagedConfigPath(newPath)) { + throw new Error("Gateway configuration is managed by Claude Code Router."); + } + originalRenameSync(oldPath, newPath); + }; +} + +function parseStartMessage(value: unknown): GatewayStartMessage { + if (!isObject(value) || value.type !== "gateway:start" || value.protocolVersion !== 1) { + throw new Error("Invalid gateway start message."); + } + if (!isObject(value.config)) { + throw new Error("Gateway start message does not include a configuration object."); + } + if (typeof value.gatewayEntry !== "string" || !value.gatewayEntry.trim()) { + throw new Error("Gateway start message does not include a runtime entry."); + } + return { + config: value.config, + gatewayEntry: path.resolve(value.gatewayEntry), + protocolVersion: 1, + type: "gateway:start" + }; +} + +function readsText(options: unknown): boolean { + if (typeof options === "string") { + return Boolean(options); + } + return isObject(options) && typeof options.encoding === "string" && Boolean(options.encoding); +} + +function isVirtualConfigPath(value: unknown): boolean { + return typeof value === "string" && path.resolve(value) === virtualConfigPath; +} + +function isManagedConfigPath(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const resolved = path.resolve(value); + return resolved === virtualConfigPath || resolved.startsWith(`${virtualConfigPath}.`); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.stack || error.message : String(error); +} diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts index a1d724de..a994928c 100644 --- a/packages/core/src/gateway/core-runtime/supervisor.ts +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -6,7 +6,13 @@ import { randomBytes } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { networkInterfaces } from "node:os"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join as pathJoin, resolve as pathResolve } from "node:path"; +import { join as pathJoin, resolve as pathResolve } from "node:path"; +import { CONFIGDIR } from "@ccr/core/config/constants"; +import { + deletePersistedRuntimeState, + loadPersistedRuntimeState, + replacePersistedRuntimeState +} from "@ccr/core/config/config-repository"; import type { AppConfig, GatewayNetworkEndpoint } from "@ccr/core/contracts/app"; import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; @@ -15,19 +21,144 @@ import { coreGatewayAuthHeader, coreGatewayAuthTokenEnv, gatewayEntryOverrideEnv import type { CoreGatewayHealth, ManagedGatewayRuntimeMarker } from "@ccr/core/gateway/internal/shared"; import { delay } from "@ccr/core/gateway/internal/clock"; +const gatewayRuntimeStateKey = "gateway"; +const gatewayConfigAcceptanceTimeoutMs = 5_000; +const gatewayStartupTimeoutMs = 15_000; -export function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): ChildProcess { +export type SpawnedGatewayProcess = { + child: ChildProcess; + configAccepted: Promise; +}; + +export function spawnGatewayProcess( + config: AppConfig, + gatewayConfig: Record, + upstreamProxyUrl: string | undefined, + runtimeId: string, + coreAuthToken: string +): SpawnedGatewayProcess { const gatewayEntry = resolveGatewayEntry(); - const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; + const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile() : undefined; const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken); - const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayEntry] : [gatewayEntry]; - return spawn(process.execPath, args, { - cwd: dirname(config.gateway.generatedConfigFile), + const gatewayBootstrapEntry = resolveGatewayBootstrapEntry(); + const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayBootstrapEntry] : [gatewayBootstrapEntry]; + const child = spawn(process.execPath, args, { + cwd: CONFIGDIR, env, - stdio: ["ignore", "pipe", "pipe"] + serialization: "advanced", + stdio: ["ignore", "pipe", "pipe", "ipc"] }); + if (!child.send) { + child.kill(); + throw new Error("Gateway runtime did not create an IPC channel."); + } + const acceptance = monitorGatewayConfigAcceptance(child); + void acceptance.promise.catch(() => undefined); + try { + child.send({ + config: gatewayConfig, + gatewayEntry, + protocolVersion: 1, + type: "gateway:start" + }, (error) => { + if (!error) { + return; + } + acceptance.reject(new Error(`Failed to send runtime config over IPC: ${formatError(error)}`)); + if (!child.killed) { + child.kill(); + } + }); + } catch (error) { + acceptance.reject(new Error(`Failed to send runtime config over IPC: ${formatError(error)}`)); + if (!child.killed) { + child.kill(); + } + } + return { + child, + configAccepted: acceptance.promise + }; } +function monitorGatewayConfigAcceptance(child: ChildProcess): { + promise: Promise; + reject: (error: Error) => void; +} { + let rejectAcceptance = (_error: Error): void => undefined; + const promise = new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const onMessage = (message: unknown) => { + if (isRecord(message) && message.type === "gateway:config-accepted" && message.protocolVersion === 1) { + finish(); + } + }; + const onError = (error: Error) => { + finish(new Error(`Core gateway failed before accepting runtime config: ${formatError(error)}`)); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + finish(new Error(`Core gateway exited with ${signal ?? code ?? "unknown status"} before accepting runtime config.`)); + }; + const timer = setTimeout(() => { + finish(new Error(`Core gateway did not accept runtime config within ${gatewayConfigAcceptanceTimeoutMs}ms.`)); + }, gatewayConfigAcceptanceTimeoutMs); + rejectAcceptance = (error) => finish(error); + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }); + return { + promise, + reject: (error) => rejectAcceptance(error) + }; +} + +function resolveGatewayBootstrapEntry(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const entry = [ + pathJoin(__dirname, "gateway-bootstrap.js"), + pathJoin(process.cwd(), ".test-dist", "core", "runtime", "gateway-bootstrap.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "gateway-bootstrap.js"), + pathJoin(resourcesPath, "app", "dist", "main", "gateway-bootstrap.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); + if (!entry) { + throw new Error("CCR gateway IPC bootstrap was not found. Run npm run build:assets."); + } + return entry; +} + +export function resolveUpstreamHeaderSanitizerEntry(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "upstream-header-sanitizer.js"), + pathJoin(process.cwd(), ".test-dist", "core", "runtime", "upstream-header-sanitizer.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "upstream-header-sanitizer.js"), + pathJoin(resourcesPath, "app", "dist", "main", "upstream-header-sanitizer.js") + ] + : []) + ].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "upstream-header-sanitizer.js"); +} function resolveGatewayEntry(): string { const override = process.env[gatewayEntryOverrideEnv]?.trim(); @@ -54,7 +185,6 @@ function resolveGatewayEntry(): string { return requireFromHere.resolve(gatewayPackageCandidates[0]); } - function resolveBundledGatewayEntry(): string | undefined { const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; return [ @@ -68,7 +198,6 @@ function resolveBundledGatewayEntry(): string | undefined { ].find((candidate) => existsSync(candidate)); } - export function resolveUndiciProxyAgentModule(): string { const bundled = resolveBundledUndiciProxyAgentModule(); if (bundled) { @@ -82,7 +211,6 @@ export function resolveUndiciProxyAgentModule(): string { } } - function resolveBundledUndiciProxyAgentModule(): string | undefined { const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; return [ @@ -96,7 +224,6 @@ function resolveBundledUndiciProxyAgentModule(): string | undefined { ].find((candidate) => existsSync(candidate)); } - function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, @@ -109,7 +236,6 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u CCR_GATEWAY_RUNTIME_ID: runtimeId, [coreGatewayAuthTokenEnv]: coreAuthToken, ELECTRON_RUN_AS_NODE: "1", - GATEWAY_CONFIG_PATH: config.gateway.generatedConfigFile, HOST: config.gateway.coreHost, PORT: String(config.gateway.corePort) }; @@ -146,9 +272,8 @@ function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | u return env; } - -export function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { - const file = pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs"); +export function writeGatewayProxyPreloadFile(): string { + const file = pathJoin(CONFIGDIR, "gateway-proxy-preload.cjs"); writeFileSync( file, [ @@ -199,7 +324,6 @@ export function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl return file; } - function mergeNoProxy(current: string | undefined, values: string[]): string { const merged = new Set(); for (const value of [...(current || "").split(","), ...values]) { @@ -211,13 +335,11 @@ function mergeNoProxy(current: string | undefined, values: string[]): string { return [...merged].join(","); } - export function endpoint(host: string, port: number): string { const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host; return `http://${endpointHost}:${port}`; } - export function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] { const normalizedHost = normalizeBindHost(host); const lanAddresses = physicalLanAddresses(); @@ -232,7 +354,6 @@ export function gatewayNetworkEndpoints(host: string, port: number): GatewayNetw })); } - function physicalLanAddresses(): Array<{ address: string; interfaceName: string }> { const seen = new Set(); const result: Array<{ address: string; interfaceName: string }> = []; @@ -263,27 +384,14 @@ function physicalLanAddresses(): Array<{ address: string; interfaceName: string ); } - function normalizeBindHost(host: string): string { return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); } - -function isLoopbackBindHost(host: string): boolean { - const normalized = normalizeBindHost(host).replace(/\.$/, ""); - return normalized === "localhost" || - normalized === "127.0.0.1" || - normalized === "::1" || - normalized === "0:0:0:0:0:0:0:1" || - /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); -} - - function isWildcardBindHost(host: string): boolean { return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; } - function isPrivateIpv4(address: string): boolean { const parts = address.split(".").map((part) => Number(part)); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { @@ -295,7 +403,6 @@ function isPrivateIpv4(address: string): boolean { (parts[0] === 192 && parts[1] === 168); } - function isVirtualNetworkInterface(interfaceName: string): boolean { const normalized = interfaceName.toLowerCase(); return [ @@ -331,34 +438,35 @@ function isVirtualNetworkInterface(interfaceName: string): boolean { ].some((pattern) => pattern.test(normalized)); } - -export async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndpoint: string): Promise { - const marker = readManagedCoreGatewayMarker(config); +export async function stopPreviousManagedCoreGateway(coreEndpoint: string): Promise { + const marker = await readManagedCoreGatewayMarker(); const markerRuntimeId = stringValue(marker?.runtimeId); const pid = numberValue(marker?.pid); if (!markerRuntimeId || !pid) { + await removeManagedCoreGatewayMarker(); + return; + } + + if (!isProcessAlive(pid)) { + await removeManagedCoreGatewayMarker(); return; } const health = await readCoreGatewayHealth(coreEndpoint); if (health?.runtimeId !== markerRuntimeId) { - return; - } - - if (!isProcessAlive(pid)) { - removeManagedCoreGatewayMarker(config); + await removeManagedCoreGatewayMarker(); return; } try { process.kill(pid, "SIGTERM"); } catch { - removeManagedCoreGatewayMarker(config); + await removeManagedCoreGatewayMarker(); return; } if (await waitForCoreGatewayStop(coreEndpoint)) { - removeManagedCoreGatewayMarker(config); + await removeManagedCoreGatewayMarker(); return; } @@ -368,12 +476,16 @@ export async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndp // Process may have exited between the health check and SIGKILL. } await waitForCoreGatewayStop(coreEndpoint); - removeManagedCoreGatewayMarker(config); + await removeManagedCoreGatewayMarker(); } +async function readManagedCoreGatewayMarker(): Promise { + const persisted = await loadPersistedRuntimeState(gatewayRuntimeStateKey); + if (isRecord(persisted)) { + return persisted; + } -function readManagedCoreGatewayMarker(config: AppConfig): ManagedGatewayRuntimeMarker | undefined { - const file = managedCoreGatewayMarkerPath(config); + const file = managedCoreGatewayMarkerPath(); if (!existsSync(file)) { return undefined; } @@ -385,50 +497,31 @@ function readManagedCoreGatewayMarker(config: AppConfig): ManagedGatewayRuntimeM } } - -export function writeManagedCoreGatewayMarker(config: AppConfig, child: ChildProcess, runtimeId: string): void { +export async function writeManagedCoreGatewayMarker(child: ChildProcess, runtimeId: string): Promise { if (!child.pid) { - return; - } - try { - writeFileSync( - managedCoreGatewayMarkerPath(config), - `${JSON.stringify( - { - generatedConfigFile: config.gateway.generatedConfigFile, - gatewayEntry: resolveGatewayEntry(), - pid: child.pid, - runtimeId, - startedAt: new Date().toISOString() - }, - null, - 2 - )}\n`, - "utf8" - ); - } catch (error) { - console.warn(`[gateway] Failed to write gateway runtime marker: ${formatError(error)}`); + throw new Error("Managed gateway process did not expose a PID."); } + await replacePersistedRuntimeState(gatewayRuntimeStateKey, { + gatewayEntry: resolveGatewayEntry(), + pid: child.pid, + runtimeId, + startedAt: new Date().toISOString() + }); } - -export function removeManagedCoreGatewayMarker(config: AppConfig | undefined): void { - if (!config) { - return; - } +export async function removeManagedCoreGatewayMarker(): Promise { + await deletePersistedRuntimeState(gatewayRuntimeStateKey); try { - rmSync(managedCoreGatewayMarkerPath(config), { force: true }); + rmSync(managedCoreGatewayMarkerPath(), { force: true }); } catch (error) { console.warn(`[gateway] Failed to remove gateway runtime marker: ${formatError(error)}`); } } - -function managedCoreGatewayMarkerPath(config: AppConfig): string { - return pathJoin(dirname(config.gateway.generatedConfigFile), gatewayRuntimeMarkerFile); +function managedCoreGatewayMarkerPath(): string { + return pathJoin(CONFIGDIR, gatewayRuntimeMarkerFile); } - async function waitForCoreGatewayStop(coreEndpoint: string): Promise { for (let index = 0; index < 20; index += 1) { if (!(await isCoreGatewayHealthy(coreEndpoint))) { @@ -439,7 +532,6 @@ async function waitForCoreGatewayStop(coreEndpoint: string): Promise { return false; } - function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -449,7 +541,6 @@ function isProcessAlive(pid: number): boolean { } } - export function assertLoopbackCoreHost(host: string): void { const error = loopbackCoreHostError(host); if (error) { @@ -457,7 +548,6 @@ export function assertLoopbackCoreHost(host: string): void { } } - export function loopbackCoreHostError(host: string): string | undefined { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "::1" @@ -465,17 +555,51 @@ export function loopbackCoreHostError(host: string): string | undefined { : "Core gateway host must be 127.0.0.1 or ::1."; } - export function generateCoreGatewayAuthToken(): string { return randomBytes(32).toString("base64url"); } - export async function isCoreGatewayHealthy(coreEndpoint: string): Promise { const health = await readCoreGatewayHealth(coreEndpoint); return health?.status === "ok"; } +export async function waitForManagedCoreGatewayReady( + coreEndpoint: string, + runtimeId: string, + child: ChildProcess +): Promise { + const deadline = Date.now() + gatewayStartupTimeoutMs; + let lastHealth: CoreGatewayHealth | undefined; + while (Date.now() < deadline) { + assertGatewayChildRunning(child); + lastHealth = await readCoreGatewayHealth(coreEndpoint); + assertGatewayChildRunning(child); + if (lastHealth?.status === "ok") { + if (lastHealth.runtimeId === runtimeId) { + return; + } + if (lastHealth.runtimeId) { + throw new Error( + `Core gateway endpoint ${coreEndpoint} is owned by a different runtime (${lastHealth.runtimeId}).` + ); + } + } + await delay(100); + } + const healthDetail = lastHealth + ? ` Last health response: ${JSON.stringify(lastHealth)}.` + : ""; + throw new Error( + `Core gateway runtime ${runtimeId} did not become healthy within ${gatewayStartupTimeoutMs}ms.${healthDetail}` + ); +} + +function assertGatewayChildRunning(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null || child.killed) { + throw new Error(`Core gateway exited during startup with ${child.signalCode ?? child.exitCode ?? "unknown status"}.`); + } +} async function readCoreGatewayHealth(coreEndpoint: string): Promise { if (!coreEndpoint) { @@ -504,19 +628,16 @@ async function readCoreGatewayHealth(coreEndpoint: string): Promise, protocol: GatewayProviderProtocol): boolean { if (protocol === "anthropic_messages") { return hasAnthropicHostedWebSearchTool(body.tools); @@ -53,8 +49,6 @@ export function hasHostedWebSearchDeclaration(body: Record, pro return false; } - - function hasOpenAiHostedWebSearchDeclaration(body: Record): boolean { if (body.web_search_options !== undefined || body.webSearchOptions !== undefined) { return true; @@ -62,8 +56,6 @@ function hasOpenAiHostedWebSearchDeclaration(body: Record): boo return Array.isArray(body.tools) && body.tools.some(isOpenAiHostedWebSearchTool); } - - function hasGeminiHostedWebSearchTool(tools: unknown): boolean { if (!Array.isArray(tools)) { return false; @@ -79,8 +71,6 @@ function hasGeminiHostedWebSearchTool(tools: unknown): boolean { }); } - - export function isAnthropicHostedWebSearchTool(tool: unknown): boolean { if (!isRecord(tool)) { return false; @@ -88,8 +78,6 @@ export function isAnthropicHostedWebSearchTool(tool: unknown): boolean { return anthropicHostedWebSearchType(stringValue(tool.type)); } - - export function isOpenAiHostedWebSearchTool(tool: unknown): boolean { if (!isRecord(tool)) { return false; @@ -97,8 +85,6 @@ export function isOpenAiHostedWebSearchTool(tool: unknown): boolean { return openAiHostedWebSearchType(stringValue(tool.type)); } - - export function openAiToolChoiceNamesWebSearch(value: unknown): boolean { if (typeof value === "string") { return openAiHostedWebSearchType(value); @@ -109,15 +95,11 @@ export function openAiToolChoiceNamesWebSearch(value: unknown): boolean { return openAiHostedWebSearchType(stringValue(value.type)); } - - function anthropicHostedWebSearchType(value: string | undefined): boolean { const normalized = normalizedToolProtocolName(value); return normalized === "web_search" || normalized === "web_search_20250305"; } - - function openAiHostedWebSearchType(value: string | undefined): boolean { const normalized = normalizedToolProtocolName(value); return normalized === "web_search" || @@ -125,14 +107,10 @@ function openAiHostedWebSearchType(value: string | undefined): boolean { normalized.startsWith("web_search_preview_"); } - - function normalizedToolProtocolName(value: string | undefined): string { return value?.trim().toLowerCase().replace(/[-.]/g, "_") ?? ""; } - - function readAnthropicWebSearchMaxUses(tools: unknown): number | undefined { if (!Array.isArray(tools)) { return undefined; @@ -141,8 +119,6 @@ function readAnthropicWebSearchMaxUses(tools: unknown): number | undefined { return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; } - - export function readHostedWebSearchMaxUses(body: Record, protocol: GatewayProviderProtocol): number | undefined { if (protocol === "anthropic_messages") { return readAnthropicWebSearchMaxUses(body.tools); @@ -154,8 +130,6 @@ export function readHostedWebSearchMaxUses(body: Record, protoc return undefined; } - - export function extractHostedWebSearchQueryHint(body: Record, protocol: GatewayProviderProtocol): string | undefined { if (protocol === "anthropic_messages") { return extractAnthropicWebSearchQueryHint(body); @@ -172,8 +146,6 @@ export function extractHostedWebSearchQueryHint(body: Record, p return undefined; } - - export function extractAnthropicWebSearchQueryHint(body: Record): string | undefined { const userTexts = Array.isArray(body.messages) ? body.messages.flatMap((message) => { @@ -186,8 +158,6 @@ export function extractAnthropicWebSearchQueryHint(body: Record return normalizedWebSearchQueryHintFromParts(userTexts); } - - export function extractClaudeCodeWebSearchToolResultQuery(body: Record): string | undefined { for (const text of claudeCodeWebSearchToolResultTexts(body)) { const quoted = /Web search results for query:\s*"([^"]+)"/i.exec(text); @@ -202,8 +172,6 @@ export function extractClaudeCodeWebSearchToolResultQuery(body: Record part.trim()) @@ -223,8 +191,6 @@ function normalizedWebSearchQueryHintFromParts(parts: string[]): string | undefi return normalizedWebSearchQueryHint(candidates.join("\n")); } - - function normalizedWebSearchQueryHint(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -236,23 +202,17 @@ function normalizedWebSearchQueryHint(value: string | undefined): string | undef return joined.trim().slice(0, 500); } - - function extractExplicitWebSearchQuery(value: string): string | undefined { const explicit = /perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*([\s\S]+)$/i.exec(value.trim()); return normalizedWebSearchQueryHint(explicit?.[1]); } - - function stripSearchIntentPrefix(value: string): string { const trimmed = value.trim(); const match = /^(?:请)?(?:帮我)?(?:搜索|查询|查一下|帮我查一下|搜一下)\s*[::]?\s*([\s\S]+)$/i.exec(trimmed); return (match?.[1] || trimmed).trim(); } - - function isRuntimeContextText(value: string): boolean { const trimmed = value.trim(); if (!trimmed) { @@ -270,8 +230,6 @@ function isRuntimeContextText(value: string): boolean { ); } - - function textPartsFromAnthropicContent(content: unknown): string[] { if (typeof content === "string") { return [content]; @@ -282,8 +240,6 @@ function textPartsFromAnthropicContent(content: unknown): string[] { return content.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); } - - export function claudeCodeWebSearchToolResultTexts(body: Record): string[] { if (!Array.isArray(body.messages)) { return []; @@ -333,8 +289,6 @@ export function claudeCodeWebSearchToolResultTexts(body: Record return texts; } - - function anthropicToolResultContentText(content: unknown): string { if (typeof content === "string") { return content; @@ -355,8 +309,6 @@ function anthropicToolResultContentText(content: unknown): string { }).join("\n"); } - - function textPartsFromOpenAiChatMessages(messages: unknown): string[] { if (!Array.isArray(messages)) { return []; @@ -369,8 +321,6 @@ function textPartsFromOpenAiChatMessages(messages: unknown): string[] { }); } - - function textPartsFromOpenAiContent(content: unknown): string[] { if (typeof content === "string") { return [content]; @@ -390,8 +340,6 @@ function textPartsFromOpenAiContent(content: unknown): string[] { }); } - - function textPartsFromOpenAiResponsesInput(input: unknown): string[] { if (typeof input === "string") { return [input]; @@ -411,8 +359,6 @@ function textPartsFromOpenAiResponsesInput(input: unknown): string[] { }); } - - function textPartsFromGeminiContents(contents: unknown): string[] { if (!Array.isArray(contents)) { return []; @@ -430,8 +376,6 @@ function textPartsFromGeminiContents(contents: unknown): string[] { }); } - - export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; for (const candidate of fusionWebSearchToolCandidates(config)) { @@ -442,14 +386,10 @@ export function fusionWebSearchToolNameForRequest(config: AppConfig, model: stri return undefined; } - - export function fusionWebSearchProviderForToolName(config: AppConfig, toolName: string): VirtualModelFusionWebSearchProvider | undefined { return fusionWebSearchToolCandidateForToolName(config, toolName)?.provider; } - - export function hostedWebSearchUnavailableMessage(config: AppConfig, toolName: string): string { const provider = fusionWebSearchProviderForToolName(config, toolName); if (provider && provider !== "browser") { @@ -461,14 +401,10 @@ export function hostedWebSearchUnavailableMessage(config: AppConfig, toolName: s return browserWebSearchUnavailableMessage(toolName); } - - function fusionWebSearchToolCandidateForToolName(config: AppConfig, toolName: string): FusionWebSearchToolCandidate | undefined { return fusionWebSearchToolCandidates(config).find((candidate) => candidate.toolName === toolName); } - - function fusionWebSearchToolCandidates(config: AppConfig): FusionWebSearchToolCandidate[] { const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; const profiles = normalizeCoreGatewayVirtualModelProfiles( @@ -506,8 +442,6 @@ function fusionWebSearchToolCandidates(config: AppConfig): FusionWebSearchToolCa return candidates; } - - export async function selectHostedWebSearchProtocolRecords( context: HostedWebSearchProtocolContext, integration?: BrowserWebSearchMcpIntegration, @@ -543,8 +477,6 @@ export async function selectHostedWebSearchProtocolRecords( return config ? selectConfiguredWebSearchProtocolRecords(context, config) : []; } - - async function selectConfiguredWebSearchProtocolRecords( context: HostedWebSearchProtocolContext, config: AppConfig @@ -584,8 +516,6 @@ async function selectConfiguredWebSearchProtocolRecords( } } - - async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise { switch (input.provider) { case "brave": @@ -605,8 +535,6 @@ async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "BRAVE_SEARCH_API_KEY"); if (!apiKey) { @@ -624,8 +552,6 @@ async function searchBrave(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "url", "description")).filter(isWebSearchProviderResult); } - - async function searchBing(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "BING_SEARCH_API_KEY"); if (!apiKey) { @@ -644,8 +570,6 @@ async function searchBing(input: WebSearchProviderInput): Promise webSearchResult(item, "name", "url", "snippet")).filter(isWebSearchProviderResult); } - - async function searchGoogleCse(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "GOOGLE_SEARCH_API_KEY"); const cx = searchEnv(input, "GOOGLE_SEARCH_CX"); @@ -663,8 +587,6 @@ async function searchGoogleCse(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); } - - async function searchSerper(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "SERPER_API_KEY"); if (!apiKey) { @@ -684,8 +606,6 @@ async function searchSerper(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); } - - async function searchSerpApi(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "SERPAPI_API_KEY"); if (!apiKey) { @@ -702,8 +622,6 @@ async function searchSerpApi(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult); } - - async function searchTavily(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "TAVILY_API_KEY"); if (!apiKey) { @@ -725,8 +643,6 @@ async function searchTavily(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "url", "content")).filter(isWebSearchProviderResult); } - - async function searchExa(input: WebSearchProviderInput): Promise { const apiKey = searchEnv(input, "EXA_API_KEY"); if (!apiKey) { @@ -749,8 +665,6 @@ async function searchExa(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "url", "text")).filter(isWebSearchProviderResult); } - - async function fetchJson(input: string, init: RequestInit): Promise { const response = await fetchWithSystemProxy(input, init); if (!response.ok) { @@ -759,14 +673,10 @@ async function fetchJson(input: string, init: RequestInit): Promise { return response.json() as Promise; } - - function searchEnv(input: WebSearchProviderInput, key: string): string | undefined { return input.env?.[key]?.trim() || process.env[key]?.trim() || undefined; } - - function webSearchResult(item: unknown, titleKey: string, urlKey: string, snippetKey: string): WebSearchProviderResult | undefined { if (!isRecord(item)) { return undefined; @@ -784,14 +694,10 @@ function webSearchResult(item: unknown, titleKey: string, urlKey: string, snippe }; } - - function isWebSearchProviderResult(value: WebSearchProviderResult | undefined): value is WebSearchProviderResult { return Boolean(value); } - - function searchProviderUrl(provider: VirtualModelFusionWebSearchProvider, query: string): string { const encoded = encodeURIComponent(query); switch (provider) { @@ -814,8 +720,6 @@ function searchProviderUrl(provider: VirtualModelFusionWebSearchProvider, query: } } - - export function selectClaudeCodeWebSearchContinuationRecords( context: ClaudeCodeWebSearchContinuationContext, integration: BrowserWebSearchMcpIntegration @@ -834,17 +738,6 @@ export function selectClaudeCodeWebSearchContinuationRecords( .slice(0, 3); } - - -async function selectAnthropicWebSearchProtocolRecords( - context: AnthropicWebSearchProtocolContext, - integration: BrowserWebSearchMcpIntegration -): Promise { - return selectHostedWebSearchProtocolRecords(context, integration); -} - - - function uniqueSearchRecordFilter(): (record: BrowserWebSearchProtocolRecord) => boolean { const seen = new Set(); return (record) => { diff --git a/packages/core/src/gateway/features/hosted-web-search/request-transform.ts b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts index f4a9dcb6..b4992001 100644 --- a/packages/core/src/gateway/features/hosted-web-search/request-transform.ts +++ b/packages/core/src/gateway/features/hosted-web-search/request-transform.ts @@ -7,8 +7,6 @@ import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints"; import { claudeCodeWebSearchToolResultTexts, extractAnthropicWebSearchQueryHint, extractClaudeCodeWebSearchToolResultQuery, extractHostedWebSearchQueryHint, fusionWebSearchToolNameForRequest, hasHostedWebSearchDeclaration, isAnthropicHostedWebSearchTool, isOpenAiHostedWebSearchTool, openAiToolChoiceNamesWebSearch, readHostedWebSearchMaxUses } from "@ccr/core/gateway/features/hosted-web-search/discovery"; import { normalizeSearchComparisonText } from "@ccr/core/gateway/features/hosted-web-search/evidence"; - - export function createHostedWebSearchProtocolContext(input: { body: Buffer | undefined; config: AppConfig; @@ -40,22 +38,6 @@ export function createHostedWebSearchProtocolContext(input: { }; } - - -function createAnthropicWebSearchProtocolContext(input: { - body: Buffer | undefined; - config: AppConfig; - method: string; - path: string; - requestId: string; - sinceMs: number; -}): AnthropicWebSearchProtocolContext | undefined { - const context = createHostedWebSearchProtocolContext(input); - return context?.protocol === "anthropic_messages" ? context : undefined; -} - - - export function createClaudeCodeWebSearchContinuationContext(input: { body: Buffer | undefined; config: AppConfig; @@ -82,8 +64,6 @@ export function createClaudeCodeWebSearchContinuationContext(input: { }; } - - export function prepareHostedWebSearchProtocolRequestBody( body: Buffer | undefined, records: BrowserWebSearchProtocolRecord[], @@ -111,8 +91,6 @@ export function prepareHostedWebSearchProtocolRequestBody( return next ? serializeJsonBody(next) : undefined; } - - export function prepareAnthropicWebSearchProtocolRequestBody( body: Buffer | undefined, records: BrowserWebSearchProtocolRecord[], @@ -133,8 +111,6 @@ export function prepareAnthropicWebSearchProtocolRequestBody( return serializeJsonBody(next); } - - export function prepareClaudeCodeWebSearchContinuationRequestBody( body: Buffer | undefined, records: BrowserWebSearchProtocolRecord[], @@ -160,8 +136,6 @@ export function prepareClaudeCodeWebSearchContinuationRequestBody( return serializeJsonBody(next); } - - function applyAnthropicWebSearchSynthesisControls(body: Record): Record { const next = { ...body }; const outputConfig = isRecord(next.output_config) ? { ...next.output_config } : {}; @@ -172,8 +146,6 @@ function applyAnthropicWebSearchSynthesisControls(body: Record) return next; } - - function prepareOpenAiChatHostedWebSearchRequestBody(body: Record, evidence: string): Record { const next = stripOpenAiHostedWebSearchTools({ ...body, @@ -182,8 +154,6 @@ function prepareOpenAiChatHostedWebSearchRequestBody(body: Record, evidence: string): Record { const next = stripOpenAiHostedWebSearchTools({ ...body, @@ -192,8 +162,6 @@ function prepareOpenAiResponsesHostedWebSearchRequestBody(body: Record, evidence: string): Record { return stripGeminiHostedWebSearchTools({ ...body, @@ -201,8 +169,6 @@ function prepareGeminiHostedWebSearchRequestBody(body: Record, }); } - - function applyOpenAiHostedWebSearchSynthesisControls(body: Record): Record { const next = { ...body }; if (typeof next.reasoning_effort === "string") { @@ -214,8 +180,6 @@ function applyOpenAiHostedWebSearchSynthesisControls(body: Record): Record { if (!Array.isArray(body.tools)) { return body; @@ -238,8 +202,6 @@ function stripAnthropicHostedWebSearchTools(body: Record): Reco return next; } - - function stripClaudeCodeWebSearchContinuationTools(body: Record): Record { if (!Array.isArray(body.tools)) { return body; @@ -250,8 +212,6 @@ function stripClaudeCodeWebSearchContinuationTools(body: Record return next; } - - function stripOpenAiHostedWebSearchTools(body: Record): Record { const next = { ...body }; let removedTools = false; @@ -276,8 +236,6 @@ function stripOpenAiHostedWebSearchTools(body: Record): Record< return next; } - - function stripGeminiHostedWebSearchTools(body: Record): Record { if (!Array.isArray(body.tools)) { return body; @@ -300,8 +258,6 @@ function stripGeminiHostedWebSearchTools(body: Record): Record< return next; } - - function stripGeminiHostedWebSearchTool(tool: unknown): { changed: boolean; value?: unknown } { if (!isRecord(tool)) { return { changed: false, value: tool }; @@ -317,8 +273,6 @@ function stripGeminiHostedWebSearchTool(tool: unknown): { changed: boolean; valu return Object.keys(next).length === 0 ? { changed, value: undefined } : { changed, value: next }; } - - function appendAnthropicSystemText(system: unknown, text: string): unknown { if (typeof system === "string") { return `${system.trimEnd()}\n\n${text}`; @@ -330,22 +284,16 @@ function appendAnthropicSystemText(system: unknown, text: string): unknown { return [block]; } - - function appendOpenAiChatSystemText(messages: unknown, text: string): unknown[] { const message = { content: text, role: "system" }; return Array.isArray(messages) ? [message, ...messages] : [message]; } - - function appendStringInstruction(value: unknown, text: string): string { const existing = rawStringValue(value); return existing ? `${existing.trimEnd()}\n\n${text}` : text; } - - function appendGeminiSystemInstruction(value: unknown, text: string): Record { const part = { text }; if (typeof value === "string") { @@ -361,8 +309,6 @@ function appendGeminiSystemInstruction(value: unknown, text: string): Record { const resultLines = record.results.slice(0, 8).map((result, resultIndex) => { @@ -403,8 +349,6 @@ function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], ].filter(Boolean).join("\n\n").slice(0, 10_000); } - - function claudeCodeWebSearchContinuationEvidenceText( records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined, @@ -428,8 +372,6 @@ function claudeCodeWebSearchContinuationEvidenceText( ].filter(Boolean).join("\n\n"); } - - function focusedWebSearchContent(content: string | undefined, queryHint: string | undefined): string | undefined { const text = content?.replace(/\s+/g, " ").trim(); if (!text) { diff --git a/packages/core/src/gateway/features/hosted-web-search/response-transform.ts b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts index f9924906..0074cb05 100644 --- a/packages/core/src/gateway/features/hosted-web-search/response-transform.ts +++ b/packages/core/src/gateway/features/hosted-web-search/response-transform.ts @@ -8,8 +8,6 @@ import { parseSseEventBlock, parseSseEvents, serializeSseEvent, shiftSseContentB import type { ParsedSseEvent } from "@ccr/core/gateway/features/hosted-web-search/sse"; import { anthropicSseTextBlockStartIndex, anthropicWebSearchProtocolBlocks, anthropicWebSearchSseEventsForBlock, mergeAnthropicWebSearchUsage, responseValueContainsAnthropicClientToolUse, responseValueContainsAnthropicWebSearchBlocks, responseValueContainsVisibleText, sanitizeAnthropicToolUseId, shouldEndAnthropicHostedWebSearchTurn, sseEventContainsAnthropicClientToolUse, sseEventContainsAnthropicWebSearchBlock, sseEventContainsVisibleText, sseEventIsAnthropicMessageEnd, sseEventsContainAnthropicClientToolUse, sseEventsContainAnthropicWebSearchBlocks, sseEventsContainVisibleText, synthesizeWebSearchAnswer, updateAnthropicWebSearchSseUsage, webSearchProtocolInsertIndex } from "@ccr/core/gateway/features/hosted-web-search/evidence"; - - export function hostedWebSearchProtocolResponseStream( input: Readable, headers: Headers, @@ -59,8 +57,6 @@ export function hostedWebSearchProtocolResponseStream( })); } - - function hostedWebSearchProtocolSseStream( input: Readable, context: HostedWebSearchProtocolContext, @@ -127,16 +123,12 @@ function hostedWebSearchProtocolSseStream( })); } - - type HostedWebSearchSseState = { done: boolean; maxOutputIndex: number; visibleText: boolean; }; - - function drainHostedWebSearchSseBlocks( stream: Transform, text: string, @@ -168,8 +160,6 @@ function drainHostedWebSearchSseBlocks( } } - - function writeHostedWebSearchSseEvent( stream: Transform, event: ParsedSseEvent, @@ -194,8 +184,6 @@ function writeHostedWebSearchSseEvent( stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); } - - function updateHostedWebSearchSseState( event: ParsedSseEvent, state: HostedWebSearchSseState, @@ -220,8 +208,6 @@ function updateHostedWebSearchSseState( } } - - function writeHostedWebSearchSseFallback( stream: Transform, state: HostedWebSearchSseState, @@ -243,8 +229,6 @@ function writeHostedWebSearchSseFallback( state.done = true; } - - function hostedWebSearchSseFallbackEvents( answer: string, state: HostedWebSearchSseState, @@ -271,8 +255,6 @@ function hostedWebSearchSseFallbackEvents( return []; } - - function anthropicHostedWebSearchProtocolSseStream( input: Readable, context: HostedWebSearchProtocolContext, @@ -343,8 +325,6 @@ function anthropicHostedWebSearchProtocolSseStream( })); } - - type AnthropicHostedWebSearchSseState = { answerInjected: boolean; hasClientToolUse: boolean; @@ -356,8 +336,6 @@ type AnthropicHostedWebSearchSseState = { visibleText: boolean; }; - - function drainAnthropicHostedWebSearchSseBlocks( stream: Transform, text: string, @@ -402,8 +380,6 @@ function drainAnthropicHostedWebSearchSseBlocks( } } - - function sseTrailingPartialBlock(text: string): string { let cursor = 0; for (const match of text.matchAll(/\r?\n\r?\n/g)) { @@ -412,8 +388,6 @@ function sseTrailingPartialBlock(text: string): string { return text.slice(cursor); } - - function writeAnthropicHostedWebSearchSseEvent( stream: Transform, event: ParsedSseEvent, @@ -447,8 +421,6 @@ function writeAnthropicHostedWebSearchSseEvent( stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); } - - function updateAnthropicHostedWebSearchSseState(event: ParsedSseEvent, state: AnthropicHostedWebSearchSseState): void { if (isRecord(event.data) && Number.isFinite(event.data.index)) { state.maxIndex = Math.max(state.maxIndex, Number(event.data.index)); @@ -458,8 +430,6 @@ function updateAnthropicHostedWebSearchSseState(event: ParsedSseEvent, state: An state.visibleText ||= sseEventContainsVisibleText(event); } - - function insertAnthropicHostedWebSearchSseBlocks( stream: Transform, state: AnthropicHostedWebSearchSseState, @@ -479,8 +449,6 @@ function insertAnthropicHostedWebSearchSseBlocks( state.injectedBlockCount += blocks.length; } - - function insertAnthropicHostedWebSearchSseAnswer( stream: Transform, state: AnthropicHostedWebSearchSseState, @@ -493,8 +461,6 @@ function insertAnthropicHostedWebSearchSseAnswer( } } - - function shiftAnthropicHostedWebSearchSseEvent( event: ParsedSseEvent, state: AnthropicHostedWebSearchSseState @@ -505,8 +471,6 @@ function shiftAnthropicHostedWebSearchSseEvent( return shiftSseContentBlockIndex(event, state.insertIndex, state.injectedBlockCount); } - - function transformHostedWebSearchProtocolResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -527,30 +491,6 @@ function transformHostedWebSearchProtocolResponseValue( return { changed: false, value }; } - - -function transformHostedWebSearchProtocolSseText( - body: string, - records: BrowserWebSearchProtocolRecord[], - context: Pick -): string { - if (context.protocol === "anthropic_messages") { - return transformAnthropicWebSearchProtocolSseText(body, records, context.requestId, context.queryHint); - } - if (context.protocol === "openai_chat_completions") { - return transformOpenAiChatHostedWebSearchSseText(body, records, context.queryHint); - } - if (context.protocol === "openai_responses") { - return transformOpenAiResponsesHostedWebSearchSseText(body, records, context.requestId, context.queryHint); - } - if (context.protocol === "gemini_generate_content") { - return transformGeminiHostedWebSearchSseText(body, records, context.queryHint); - } - return body; -} - - - export function transformAnthropicWebSearchProtocolResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -593,8 +533,6 @@ export function transformAnthropicWebSearchProtocolResponseValue( }; } - - export function transformAnthropicWebSearchProtocolSseText( body: string, records: BrowserWebSearchProtocolRecord[], @@ -653,8 +591,6 @@ export function transformAnthropicWebSearchProtocolSseText( return `${shiftedEvents.map(serializeSseEvent).join("\n\n")}\n\n`; } - - export function transformOpenAiChatHostedWebSearchResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -691,8 +627,6 @@ export function transformOpenAiChatHostedWebSearchResponseValue( }; } - - export function transformOpenAiChatHostedWebSearchSseText( body: string, records: BrowserWebSearchProtocolRecord[], @@ -725,8 +659,6 @@ export function transformOpenAiChatHostedWebSearchSseText( return `${shifted.map(serializeSseEvent).join("\n\n")}\n\n`; } - - export function transformOpenAiResponsesHostedWebSearchResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -757,8 +689,6 @@ export function transformOpenAiResponsesHostedWebSearchResponseValue( return { changed: true, value: next }; } - - export function transformOpenAiResponsesHostedWebSearchSseText( body: string, records: BrowserWebSearchProtocolRecord[], @@ -785,8 +715,6 @@ export function transformOpenAiResponsesHostedWebSearchSseText( return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(shifted, answer)); } - - export function transformGeminiHostedWebSearchResponseValue( value: unknown, records: BrowserWebSearchProtocolRecord[], @@ -829,8 +757,6 @@ export function transformGeminiHostedWebSearchResponseValue( }; } - - export function transformGeminiHostedWebSearchSseText( body: string, records: BrowserWebSearchProtocolRecord[], @@ -850,8 +776,6 @@ export function transformGeminiHostedWebSearchSseText( return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; } - - function openAiChatResponseContainsVisibleText(value: Record): boolean { if (!Array.isArray(value.choices)) { return false; @@ -862,8 +786,6 @@ function openAiChatResponseContainsVisibleText(value: Record): }); } - - function openAiChatSseContainsVisibleText(events: ParsedSseEvent[]): boolean { return events.some((event) => { const choices = isRecord(event.data) && Array.isArray(event.data.choices) ? event.data.choices : []; @@ -874,8 +796,6 @@ function openAiChatSseContainsVisibleText(events: ParsedSseEvent[]): boolean { }); } - - function updateOpenAiChatSseFinishReason(event: ParsedSseEvent): ParsedSseEvent { if (!isRecord(event.data) || !Array.isArray(event.data.choices)) { return event; @@ -891,14 +811,10 @@ function updateOpenAiChatSseFinishReason(event: ParsedSseEvent): ParsedSseEvent return changed ? { ...event, data: { ...event.data, choices } } : event; } - - function openAiResponsesValueContainsVisibleText(value: Record): boolean { return Array.isArray(value.output) && value.output.some(openAiResponsesItemContainsVisibleText); } - - function openAiResponsesItemContainsVisibleText(item: unknown): boolean { if (!isRecord(item)) { return false; @@ -909,14 +825,10 @@ function openAiResponsesItemContainsVisibleText(item: unknown): boolean { return Boolean(stringValue(item.text)?.trim()); } - - function openAiResponsesSseContainsVisibleText(events: ParsedSseEvent[]): boolean { return Boolean(openAiResponsesSseVisibleText(events)); } - - function openAiResponsesSseVisibleText(events: ParsedSseEvent[]): string | undefined { let deltaText = ""; let doneText = ""; @@ -943,8 +855,6 @@ function openAiResponsesSseVisibleText(events: ParsedSseEvent[]): string | undef return nonEmptyText(deltaText) ?? nonEmptyText(doneText) ?? nonEmptyText(itemText); } - - function openAiResponsesItemText(item: unknown): string | undefined { if (!isRecord(item)) { return undefined; @@ -958,14 +868,10 @@ function openAiResponsesItemText(item: unknown): string | undefined { return stringValue(item.text); } - - function nonEmptyText(value: string | undefined): string | undefined { return value && value.trim() ? value : undefined; } - - function openAiResponsesWebSearchCallItems(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { return records.map((record, index) => ({ action: { @@ -978,8 +884,6 @@ function openAiResponsesWebSearchCallItems(records: BrowserWebSearchProtocolReco })); } - - function openAiResponsesMessageItem(answer: string, requestId: string): Record { return { content: [{ annotations: [], text: answer, type: "output_text" }], @@ -990,8 +894,6 @@ function openAiResponsesMessageItem(answer: string, requestId: string): Record { const data = isRecord(event.data) ? event.data : undefined; @@ -1001,8 +903,6 @@ function nextOpenAiResponsesOutputIndex(events: ParsedSseEvent[]): number { return indexes.length === 0 ? 0 : Math.max(...indexes) + 1; } - - function openAiResponsesSseAnswerEvents(answer: string, requestId: string, outputIndex: number): ParsedSseEvent[] { const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; const contentIndex = 0; @@ -1059,8 +959,6 @@ function openAiResponsesSseAnswerEvents(answer: string, requestId: string, outpu ]; } - - function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseEvent { if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { return event; @@ -1080,8 +978,6 @@ function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseE }; } - - function normalizeOpenAiResponsesSseEvents(events: ParsedSseEvent[], visibleText?: string): ParsedSseEvent[] { const outputIndexMap = openAiResponsesOutputIndexMap(events); return events.flatMap((event) => { @@ -1095,14 +991,10 @@ function normalizeOpenAiResponsesSseEvents(events: ParsedSseEvent[], visibleText }); } - - function serializeOpenAiResponsesSseEvents(events: ParsedSseEvent[]): string { return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; } - - function openAiResponsesOutputIndexMap(events: ParsedSseEvent[]): Map { const indexes: number[] = []; for (const event of events) { @@ -1118,8 +1010,6 @@ function openAiResponsesOutputIndexMap(events: ParsedSseEvent[]): Map left - right).map((index, nextIndex) => [index, nextIndex])); } - - function remapOpenAiResponsesOutputIndex(event: ParsedSseEvent, outputIndexMap: Map): ParsedSseEvent { if (!isRecord(event.data)) { return event; @@ -1137,8 +1027,6 @@ function remapOpenAiResponsesOutputIndex(event: ParsedSseEvent, outputIndexMap: }; } - - function updateOpenAiResponsesCompletedOutput(event: ParsedSseEvent, visibleText?: string): ParsedSseEvent { if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { return event; @@ -1167,8 +1055,6 @@ function updateOpenAiResponsesCompletedOutput(event: ParsedSseEvent, visibleText }; } - - function isOpenAiResponsesReasoningSseEvent(event: ParsedSseEvent): boolean { const data = isRecord(event.data) ? event.data : undefined; if (!data) { @@ -1186,34 +1072,24 @@ function isOpenAiResponsesReasoningSseEvent(event: ParsedSseEvent): boolean { return stringValue(part?.type) === "reasoning_text"; } - - function geminiResponseValueContainsVisibleText(value: Record): boolean { return Array.isArray(value.candidates) && value.candidates.some(geminiCandidateContainsVisibleText); } - - function geminiResponseArrayContainsVisibleText(values: unknown[]): boolean { return values.some((value) => isRecord(value) && geminiResponseValueContainsVisibleText(value)); } - - function geminiCandidateContainsVisibleText(candidate: unknown): boolean { const content = isRecord(candidate) && isRecord(candidate.content) ? candidate.content : undefined; const parts = Array.isArray(content?.parts) ? content.parts : []; return parts.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); } - - function geminiSseContainsVisibleText(events: ParsedSseEvent[]): boolean { return events.some((event) => isRecord(event.data) && geminiResponseValueContainsVisibleText(event.data)); } - - function geminiAnswerCandidate(answer: string): Record { return { content: { @@ -1225,28 +1101,20 @@ function geminiAnswerCandidate(answer: string): Record { }; } - - function geminiAnswerCandidateChunk(answer: string): Record { return { candidates: [geminiAnswerCandidate(answer)] }; } - - function firstSseDataRecord(events: ParsedSseEvent[]): Record | undefined { return events.map((event) => isRecord(event.data) ? event.data : undefined).find(Boolean); } - - function doneSseEventIndex(events: ParsedSseEvent[]): number { return events.findIndex((event) => event.raw?.includes("[DONE]")); } - - function sseEventIsDone(event: ParsedSseEvent): boolean { return Boolean(event.raw?.includes("[DONE]")); } diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts index ba430d26..734bbe15 100644 --- a/packages/core/src/gateway/http/request-handler.ts +++ b/packages/core/src/gateway/http/request-handler.ts @@ -16,7 +16,7 @@ import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/g import { authorize, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; -import { applyCors, endpoint, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; +import { applyCors, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; import { billingUsageSyncPath, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared"; import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/internal/shared"; diff --git a/packages/core/src/gateway/internal/shared.ts b/packages/core/src/gateway/internal/shared.ts index 735ee1d8..b9a432c8 100644 --- a/packages/core/src/gateway/internal/shared.ts +++ b/packages/core/src/gateway/internal/shared.ts @@ -129,7 +129,6 @@ export type CoreGatewayHealth = { export type ManagedGatewayRuntimeMarker = { - generatedConfigFile?: unknown; gatewayEntry?: unknown; pid?: unknown; runtimeId?: unknown; diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index 7bd7ec51..47b44b74 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -37,7 +37,6 @@ import { createGatewayModelsResponse, prepareClaudeAppDiscoveredModelRequest, pr import { resolveProviderLogName, resolveResponseProviderProtocol, sanitizeHeaderValue } from "@ccr/core/providers/runtime-topology"; import { createBodySampler, requestLogSampled, shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; import { RequestRouteTraceRecorder } from "@ccr/core/observability/route-trace"; -import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; import { coreGatewayUsageAttributionConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; import { providerModelPricingForUsage } from "@ccr/core/models/pricing-service"; import { clientClosedRequestStatusCode, clientDisconnectMessage, resolveStreamRequestLogOutcome, UpstreamRequestError } from "@ccr/core/gateway/internal/shared"; diff --git a/packages/core/src/mcp/toolhub-mcp.ts b/packages/core/src/mcp/toolhub-mcp.ts index a57ca6ab..78bcc423 100644 --- a/packages/core/src/mcp/toolhub-mcp.ts +++ b/packages/core/src/mcp/toolhub-mcp.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import OpenAI from "openai"; diff --git a/packages/core/src/media/storage.ts b/packages/core/src/media/storage.ts index 81910b1a..b24b8c44 100644 --- a/packages/core/src/media/storage.ts +++ b/packages/core/src/media/storage.ts @@ -93,7 +93,7 @@ export class MediaJobStore { export class MediaArtifactStore { readonly artifactsDir: string; - constructor(private readonly rootDir: string) { + constructor(rootDir: string) { this.artifactsDir = path.join(rootDir, "artifacts"); mkdirSync(this.artifactsDir, { mode: privateDirectoryMode, recursive: true }); } diff --git a/packages/core/src/plugins/service.ts b/packages/core/src/plugins/service.ts index ab233e89..5984b150 100644 --- a/packages/core/src/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -195,14 +195,12 @@ class GatewayPluginService { private proxyRoutes: RegisteredProxyRoute[] = []; private providerAccountConnectors = new Map(); private resourceOwnerIds = new Set(); - private running = false; private stopHooks: StopHook[] = []; private virtualModelProfiles: unknown[] = []; async start(config: AppConfig): Promise { await this.stop({ nextConfig: config }); this.config = config; - this.running = true; for (const pluginConfig of config.plugins ?? []) { if (pluginConfig.enabled === false) { @@ -248,7 +246,6 @@ class GatewayPluginService { this.gatewayRoutes = []; this.proxyRoutes = []; this.providerAccountConnectors.clear(); - this.running = false; this.virtualModelProfiles = []; } diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index 31587932..0035564b 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -444,19 +444,3 @@ function windowsCommandQuote(value: string): string { function isWindowsCommandScript(command: string): boolean { return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(path.basename(command)); } - -function windowsCommandScriptInvocation(command: string, args: string[]): string { - return [ - "call", - windowsCommandInvocationArg(command), - ...args.map(windowsCommandInvocationArg) - ].join(" "); -} - -function windowsCommandInvocationArg(value: string): string { - const normalized = value.replace(/\r?\n/g, " "); - if (!normalized) { - return "\"\""; - } - return `"${normalized.replace(/[\^"%&|<>()]/g, "^$&")}"`; -} diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index a0afa9b1..e6cb3d9a 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -30,6 +30,7 @@ export const CCR_CLI_COMPANION_RUNTIME_FILE_NAMES = [ "browser-web-search-proxy-mcp.js", "fusion-tool-fallback-mcp.js", "fusion-vision-mcp.js", + "gateway-bootstrap.js", "media-tools-proxy-mcp.js", "next-ai-gateway.js", "request-log-worker.js", @@ -1116,20 +1117,6 @@ function sendProfileProcessSignal(pid: number | undefined, signal: NodeJS.Signal } } -async function waitForProcessExit(pid: number | undefined, timeoutMs: number): Promise { - if (!pid) { - return true; - } - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (!isProcessAlive(pid)) { - return true; - } - await sleep(100); - } - return !isProcessAlive(pid); -} - async function waitForProfileAppStart(entry: Pick, timeoutMs: number): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 966b07cb..ae570bbe 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -3,7 +3,7 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync import os from "node:os"; import path from "node:path"; import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, availableGatewayModelIds, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, isGatewayProviderEnabled, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app"; -import { replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { replacePersistedApiKeys } from "@ccr/core/config/config-repository"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { CLAUDE_CODE_MCP_CONFIG_ENV, @@ -32,7 +32,6 @@ import { import { piWrapperFilename, resolvePiAgentDir, - resolvePiSessionDir, writePiGatewayConfig } from "@ccr/core/agents/pi/profile-config"; import { CONFIGDIR } from "@ccr/core/config/constants"; @@ -3233,31 +3232,6 @@ function unavailableModelStatus(profile: ProfileConfig, file: string): ProfileCl }; } -function disabledProfileMessage(profile: ProfileConfig): string { - if (profile.agent === "claude-code") { - return "Claude Code profile is disabled."; - } - if (profile.agent === "grok") { - return "Grok CLI profile is disabled."; - } - if (profile.agent === "kimi") { - return "Kimi CLI profile is disabled."; - } - if (profile.agent === "pi") { - return "Pi profile is disabled."; - } - if (profile.agent === "claude-design") { - return "Claude Design profile is disabled."; - } - if (profile.agent === "opencode") { - return "OpenCode profile is disabled."; - } - if (profile.agent === "kilo") { - return "Kilo CLI profile is disabled."; - } - return `${codexCompatibleClientName(profile.agent)} profile is disabled.`; -} - function isGlobalProfile(profile: ProfileConfig): boolean { return normalizeProfileScope(profile.scope) === "global"; } @@ -3494,10 +3468,6 @@ function tomlInlineStringTable(values: Record): string { .join(", ")} }`; } -function tomlStringContent(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r"); -} - function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } diff --git a/packages/core/src/providers/manifest-service.ts b/packages/core/src/providers/manifest-service.ts index 40bdf896..73f5d4ec 100644 --- a/packages/core/src/providers/manifest-service.ts +++ b/packages/core/src/providers/manifest-service.ts @@ -5,7 +5,6 @@ import { parseProviderManifestPayload } from "@ccr/core/contracts/deep-link"; import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey, providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index"; import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; import type { - GatewayProviderConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig, ProviderAccountStandardConnectorConfig, diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 98602152..bda57184 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -1217,7 +1217,7 @@ function protocolHints(value: string): GatewayProviderCapabilityProtocol[] { function isProtocolSupported( status: number | undefined, message: string, - protocol?: GatewayProviderCapabilityProtocol + _protocol?: GatewayProviderCapabilityProtocol ): boolean { if (status === undefined) { return false; diff --git a/packages/core/src/providers/runtime-topology.ts b/packages/core/src/providers/runtime-topology.ts index b1836c50..c4676754 100644 --- a/packages/core/src/providers/runtime-topology.ts +++ b/packages/core/src/providers/runtime-topology.ts @@ -100,7 +100,6 @@ export function providerCredentialPriority(credential: ProviderCredentialConfig, return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; } - export function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] { if (!isGatewayProviderEnabled(provider)) { return []; @@ -115,7 +114,6 @@ export function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGat .filter((item): item is CoreGatewayProvider => Boolean(item)); } - function toCoreGatewayProvidersForCapability( provider: GatewayProviderConfig, capability?: GatewayProviderCapability @@ -131,7 +129,6 @@ function toCoreGatewayProvidersForCapability( .filter((item): item is CoreGatewayProvider => Boolean(item)); } - function toCoreGatewayProvider( provider: GatewayProviderConfig, capability?: GatewayProviderCapability, @@ -173,7 +170,6 @@ function toCoreGatewayProvider( }; } - export function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] { return [...credentials].sort((left, right) => providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) || @@ -181,7 +177,6 @@ export function sortProviderCredentialsForConfig(credentials: ProviderCredential ); } - export function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] { const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : []; const normalized: GatewayProviderCapability[] = []; @@ -212,7 +207,6 @@ export function normalizedProviderCapabilities(provider: GatewayProviderConfig): return applyPresetProtocolLock(provider, normalized); } - function applyPresetProtocolLock( provider: GatewayProviderConfig, capabilities: GatewayProviderCapability[] @@ -239,7 +233,6 @@ function applyPresetProtocolLock( : []; } - function lockedProviderPresetProtocols( provider: GatewayProviderConfig, capabilities: GatewayProviderCapability[] @@ -265,7 +258,6 @@ function lockedProviderPresetProtocols( return []; } - function providerCapabilityPriority(capability: GatewayProviderCapability): number { if (capability.source === "preset") { return 0; @@ -276,24 +268,20 @@ function providerCapabilityPriority(capability: GatewayProviderCapability): numb return 1; } - export function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderCapabilityProtocol): string { return `${providerRuntimeId(provider)}::${protocol}`; } - function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderCapabilityProtocol): string { return `${providerName}::${protocol}`; } - export function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderCapabilityProtocol, value: string): boolean { const normalized = value.trim().toLowerCase(); return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized || providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; } - export function sanitizeHeaderValue(value: unknown): string { // HTTP header values must be ByteString (code point <= 255). Values derived // from user-facing names — model selectors like "小米mimo/...", provider @@ -308,7 +296,6 @@ export function sanitizeHeaderValue(value: unknown): string { return sanitized || "unknown"; } - export function providerCredentialInternalName( provider: GatewayProviderConfig, protocol: GatewayProviderCapabilityProtocol, @@ -317,7 +304,6 @@ export function providerCredentialInternalName( return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; } - export function parseProviderCredentialInternalName(value: string | undefined): { credentialSlug: string; providerId: string; @@ -339,7 +325,6 @@ export function parseProviderCredentialInternalName(value: string | undefined): return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined; } - export function providerCredentialSlug(value: string | undefined): string { return (value ?? "") .trim() @@ -348,7 +333,6 @@ export function providerCredentialSlug(value: string | undefined): string { .replace(/^-+|-+$/g, "") || "key"; } - export function providerCredentialRuntimeId( provider: GatewayProviderConfig, credential: ProviderCredentialConfig, @@ -363,17 +347,14 @@ export function providerCredentialRuntimeId( return label ? `${providerCredentialSlug(label)}-${oneBasedIndex}` : `key-${oneBasedIndex}`; } - function providerCredentialSortKey(credential: ProviderCredentialConfig): string { return providerCredentialSlug(credential.id || credential.name || credential.label); } - export function providerCredentialApiKey(credential: ProviderCredentialConfig): string { return credential.api_key || credential.apiKey || credential.apikey || ""; } - export function findProviderCredentialByRuntimeId( provider: GatewayProviderConfig, credentialId: string @@ -386,7 +367,6 @@ export function findProviderCredentialByRuntimeId( }); } - export function findProviderCredentialBySlug( provider: GatewayProviderConfig, credentialSlug: string @@ -395,7 +375,6 @@ export function findProviderCredentialBySlug( return (provider.credentials ?? []).find((credential, index) => providerCredentialSlug(providerCredentialRuntimeId(provider, credential, index)) === normalizedSlug); } - export function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { if (typeof value !== "string") { return undefined; @@ -447,7 +426,6 @@ export function normalizeProviderCapabilityProtocol(value: unknown): GatewayProv return undefined; } - export function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { const url = readBaseUrl(provider)?.toLowerCase() ?? ""; const transformerNames = JSON.stringify(provider.transformer ?? "").toLowerCase(); @@ -463,7 +441,6 @@ export function inferProtocol(provider: GatewayProviderConfig): GatewayProviderP return "openai_chat_completions"; } - export function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | undefined): GatewayProviderProtocol | undefined { const ccrProtocol = normalizeProviderProtocol(headers.get("x-ccr-provider-protocol")); if (ccrProtocol) { @@ -492,7 +469,6 @@ export function resolveResponseProviderProtocol(headers: Headers, config: AppCon return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); } - export function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined { const providerSelector = headers.get("x-gateway-target-provider-name")?.trim() || @@ -511,15 +487,6 @@ export function resolveProviderLogName(headers: Headers, config: AppConfig | und return modelProvider?.name; } - -function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean { - const normalizedName = name.trim().toLowerCase(); - return [provider.id, provider.name, provider.provider] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .some((value) => value.trim().toLowerCase() === normalizedName); -} - - function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderCapabilityProtocol): string | undefined { if (!value) { return undefined; @@ -529,7 +496,6 @@ function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: Gatewa : normalizeProviderBaseUrlInput(value) || undefined; } - function readBaseUrl(provider: GatewayProviderConfig): string | undefined { return provider.baseurl || provider.baseUrl || provider.api_base_url; } diff --git a/packages/core/src/proxy/service.ts b/packages/core/src/proxy/service.ts index bc1782c6..bbfa3741 100644 --- a/packages/core/src/proxy/service.ts +++ b/packages/core/src/proxy/service.ts @@ -15,7 +15,6 @@ import type { ProxyForwardMode, ProxyCertificateInstallResult, ProxyCertificateStatus, - ProxyMode, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, diff --git a/packages/core/src/runtime/app-paths.ts b/packages/core/src/runtime/app-paths.ts index 874977f5..62154052 100644 --- a/packages/core/src/runtime/app-paths.ts +++ b/packages/core/src/runtime/app-paths.ts @@ -3,7 +3,6 @@ import path from "node:path"; export const APP_NAME = "Claude Code Router"; export const APP_STORAGE_NAME = "claude-code-router"; -export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); const homeDirEnv = "CCR_INTERNAL_HOME_DIR"; const appDataDirEnv = "CCR_INTERNAL_APP_DATA_DIR"; @@ -33,6 +32,8 @@ export function resolveRuntimeAppPath(name: RuntimePathName): string { return fallbackUserDataDir(); } +export const LEGACY_CONFIGDIR = path.join(resolveRuntimeAppPath("home"), ".claude-code-router"); + export function resolveRuntimeConfigDir(): string { if (process.platform === "win32") { return path.join(resolveRuntimeAppPath("appData"), APP_STORAGE_NAME); diff --git a/packages/core/src/usage/store.ts b/packages/core/src/usage/store.ts index eec0aade..7420ac01 100644 --- a/packages/core/src/usage/store.ts +++ b/packages/core/src/usage/store.ts @@ -888,100 +888,6 @@ function buildBuckets( }); } -function buildModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { - const grouped = new Map(); - for (const event of events) { - const key = `${event.provider}::${event.model}`; - const bucket = grouped.get(key) ?? []; - bucket.push(event); - grouped.set(key, bucket); - } - - const rows = Array.from(grouped.entries()) - .map(([key, groupedEvents]) => { - const latest = groupedEvents.at(-1); - return { - ...buildTotals(groupedEvents), - caption: latest?.provider || "unknown", - credentialId: latest?.credentialId || undefined, - key, - label: latest?.model || "unknown", - maxShare: 0, - model: latest?.model, - provider: latest?.provider - }; - }) - .sort((a, b) => b.totalTokens - a.totalTokens || b.requestCount - a.requestCount) - .slice(0, 8); - - return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); -} - -function buildClientModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { - const grouped = new Map(); - for (const event of events) { - const key = `${event.client}::${event.provider}::${event.credentialId}::${event.model}`; - const bucket = grouped.get(key) ?? []; - bucket.push(event); - grouped.set(key, bucket); - } - - const rows = Array.from(grouped.entries()) - .map(([key, groupedEvents]) => { - const latest = groupedEvents.at(-1); - const model = latest?.model || "unknown"; - const provider = latest?.provider || "unknown"; - const credentialId = latest?.credentialId || ""; - return { - ...buildTotals(groupedEvents), - caption: credentialId ? `${provider} / ${credentialId} / ${model}` : `${provider} / ${model}`, - client: latest?.client, - credentialId: credentialId || undefined, - key, - label: latest?.client || "unknown", - maxShare: 0, - model, - provider - }; - }) - .sort(compareUsageRows) - .slice(0, 25); - - return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); -} - -function buildProviderModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { - const grouped = new Map(); - for (const event of events) { - const key = `${event.provider}::${event.credentialId}::${event.model}`; - const bucket = grouped.get(key) ?? []; - bucket.push(event); - grouped.set(key, bucket); - } - - const rows = Array.from(grouped.entries()) - .map(([key, groupedEvents]) => { - const latest = groupedEvents.at(-1); - const model = latest?.model || "unknown"; - const provider = latest?.provider || "unknown"; - const credentialId = latest?.credentialId || ""; - return { - ...buildTotals(groupedEvents), - caption: credentialId ? `${credentialId} / ${model}` : model, - credentialId: credentialId || undefined, - key, - label: provider, - maxShare: 0, - model, - provider - }; - }) - .sort(compareUsageRows) - .slice(0, 25); - - return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); -} - function buildRecentRequestRows(events: StoredUsageEvent[]): UsageComparisonRow[] { const recent = events.slice(-10).reverse(); const rows = recent.map((event) => ({ @@ -1000,10 +906,6 @@ function buildRecentRequestRows(events: StoredUsageEvent[]): UsageComparisonRow[ return applyMaxShare(rows, (row) => row.totalTokens || row.avgDurationMs || 1); } -function compareUsageRows(a: UsageComparisonRow, b: UsageComparisonRow): number { - return b.totalTokens - a.totalTokens || b.requestCount - a.requestCount || a.label.localeCompare(b.label); -} - function applyMaxShare( rows: T[], readValue: (row: T) => number diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 40010e3d..c24e9716 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -6,14 +6,27 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import packageJson from "../../package.json"; -import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; +import { loadOnboardingFinished, markOnboardingFinished } from "@ccr/core/config/onboarding-state"; import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; -import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; +import { + APP_CONFIG_DB_FILE, + APP_NAME, + CONFIGDIR, + DATADIR, + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_APP_CONFIG_DB_FILES, + LEGACY_API_KEYS_DB_FILES, + LEGACY_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE, + PROXY_CA_CERT_FILE, + REQUEST_LOGS_DB_FILE, + USAGE_DB_FILE +} from "@ccr/core/config/constants"; import { detectProviderIcon } from "@ccr/core/providers/icons"; import { fetchProviderManifest } from "@ccr/core/providers/manifest-service"; import { getLocalAgentProviderCandidates, importLocalAgentProvider, probeLocalAgentProvider } from "@ccr/core/agents/local-providers/service"; @@ -105,7 +118,6 @@ type WebManagementSecurityContext = { const defaultWebHost = "127.0.0.1"; const defaultWebPort = 3458; -const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const maxRpcBodyBytes = 8 * 1024 * 1024; const webAuthHeader = "x-ccr-web-auth"; const webAuthQueryParam = "ccr_web_token"; @@ -279,7 +291,7 @@ const rpcHandlers: Record = { serviceToken === process.env.CCR_SERVICE_INSTANCE_TOKEN }), getLocalAgentProviderCandidates: () => getLocalAgentProviderCandidates(), - getOnboardingFinished: async () => Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)), + getOnboardingFinished: () => loadOnboardingFinished(), getPluginMarketplace, getProfileOpenCommand: async (request) => getProfileOpenCommand(await loadAppConfig(), request as ProfileOpenRequest), getProfileRuntimeStatus: () => getProfileRuntimeStatus(), @@ -396,7 +408,7 @@ const rpcHandlers: Record = { scanBotHandoffWifiTargets: () => scanBotHandoffWifiTargets(), selectPluginDirectory: (directory) => inspectPluginDirectory(readRequiredString(directory, "Plugin directory path is required.")), setOnboardingFinished: async () => { - await replacePersistedAppSetting(onboardingFinishedAtSettingKey, new Date().toISOString()); + await markOnboardingFinished(); return true; }, setProxyNetworkCaptureEnabled: (enabled) => proxyService.setNetworkCaptureEnabled(Boolean(enabled)), @@ -479,14 +491,11 @@ function getCliAppInfo(): AppInfo { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; return { - appConfigDbFile: APP_CONFIG_DB_FILE, - apiKeysDbFile: API_KEYS_DB_FILE, ...(chatgptAppPath ? { chatgptAppPath } : {}), + configDbFile: APP_CONFIG_DB_FILE, configDir: CONFIGDIR, - configFile: CONFIG_FILE, dataDir: DATADIR, desktop: false, - gatewayConfigFile: GATEWAY_CONFIG_FILE, launchAtLoginSupported: false, name: APP_NAME, ...(opencodeAppPath ? { opencodeAppPath } : {}), @@ -729,7 +738,7 @@ async function exportAppData(): Promise { version: packageJson.version }, appState: { - onboardingFinished: Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)) + onboardingFinished: await loadOnboardingFinished() }, config: await loadAppConfig(), exportedAt, @@ -775,7 +784,8 @@ function readDataExportFiles(): Array<{ base64: string; name: string; path: stri function dataExportCandidateFiles(): string[] { return uniqueStrings([ ...sqliteDataFiles(APP_CONFIG_DB_FILE), - ...sqliteDataFiles(API_KEYS_DB_FILE), + ...LEGACY_APP_CONFIG_DB_FILES.flatMap(sqliteDataFiles), + ...LEGACY_API_KEYS_DB_FILES.flatMap(sqliteDataFiles), ...sqliteDataFiles(REQUEST_LOGS_DB_FILE), ...sqliteDataFiles(USAGE_DB_FILE) ]); @@ -788,12 +798,9 @@ function sqliteDataFiles(file: string): string[] { function assertExportTargetIsNotInternalDataFile(file: string): void { const target = path.resolve(file); const reserved = new Set([ - CONFIG_FILE, + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE, LEGACY_CONFIG_FILE, - APP_CONFIG_DB_FILE, - API_KEYS_DB_FILE, - REQUEST_LOGS_DB_FILE, - USAGE_DB_FILE, ...dataExportCandidateFiles() ].map((item) => path.resolve(item))); if (reserved.has(target)) { diff --git a/packages/core/test/architecture/gateway-service-architecture.test.mjs b/packages/core/test/architecture/gateway-service-architecture.test.mjs index 9bd160b7..05aa14b6 100644 --- a/packages/core/test/architecture/gateway-service-architecture.test.mjs +++ b/packages/core/test/architecture/gateway-service-architecture.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import test from "node:test"; @@ -39,17 +39,12 @@ test("gateway implementation modules do not depend on the public facade", () => assert.deepEqual(reverseDependencies, []); }); -test("core config compilation is separated from filesystem persistence", () => { +test("core config compilation has no filesystem persistence", () => { const compiler = readFileSync( path.join(gatewayRoot, "core-runtime", "config-compiler.ts"), "utf8" ); - const writer = readFileSync( - path.join(gatewayRoot, "core-runtime", "config-writer.ts"), - "utf8" - ); assert.doesNotMatch(compiler, /node:fs|writeFileSync|mkdirSync/); - assert.match(writer, /compileCoreGatewayConfig/); - assert.match(writer, /writeFileSync/); + assert.equal(existsSync(path.join(gatewayRoot, "core-runtime", "config-writer.ts")), false); }); diff --git a/packages/core/test/integration/gateway/gateway-client-disconnect.test.mjs b/packages/core/test/integration/gateway/gateway-client-disconnect.test.mjs index 271e149e..ab721ffc 100644 --- a/packages/core/test/integration/gateway/gateway-client-disconnect.test.mjs +++ b/packages/core/test/integration/gateway/gateway-client-disconnect.test.mjs @@ -65,7 +65,7 @@ test("gateway treats downstream client aborts as expected stream cleanup", async } await waitForTcpListener(upstream); const upstreamPort = serverPort(upstream); - const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.config.json") }); + const config = createDefaultAppConfig(); config.APIKEY = "test-api-key"; config.gateway.coreHost = "127.0.0.1"; config.gateway.corePort = upstreamPort; diff --git a/packages/core/test/integration/mcp/grok-media-service.test.mjs b/packages/core/test/integration/mcp/grok-media-service.test.mjs index f3792f49..f962c216 100644 --- a/packages/core/test/integration/mcp/grok-media-service.test.mjs +++ b/packages/core/test/integration/mcp/grok-media-service.test.mjs @@ -141,7 +141,7 @@ test("OpenAI video bindings expose the exact cross-protocol parameter subset", ( test("an imported Grok Agent supplies OAuth-backed Grok API media models without an API key", async (t) => { const root = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-agent-media-")); - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.mediaTools.enabled = true; config.Providers = [normalizeGrokProviderMediaCapabilities({ apiKey: "ccr-local-agent-login", @@ -363,7 +363,7 @@ test("provider video jobs return immediately and finish through asynchronous pol }); function mediaConfig(baseUrlValue) { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.mediaTools = { ...config.mediaTools, artifactTtlHours: 1, diff --git a/packages/core/test/integration/mcp/local-ai-gateway-media-live.test.mjs b/packages/core/test/integration/mcp/local-ai-gateway-media-live.test.mjs index f8d5c18b..60398daf 100644 --- a/packages/core/test/integration/mcp/local-ai-gateway-media-live.test.mjs +++ b/packages/core/test/integration/mcp/local-ai-gateway-media-live.test.mjs @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import { randomBytes, randomUUID } from "node:crypto"; import { createServer } from "node:http"; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { grokCandidate, importGrokProvider } from "@ccr/core/agents/local-providers/grok.ts"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; -import { writeCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-writer.ts"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts"; import { isCoreGatewayHealthy, spawnGatewayProcess } from "@ccr/core/gateway/core-runtime/supervisor.ts"; import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared.ts"; import { MediaService } from "@ccr/core/media/service.ts"; @@ -38,7 +38,7 @@ test("Fusion generates image and video through the local ai-gateway", { skip: !l const configRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-fusion-live-config-")); const artifactRoot = path.join(os.tmpdir(), `ccr-fusion-live-artifacts-${Date.now()}`); - const config = createDefaultAppConfig({ generatedConfigFile: path.join(configRoot, "gateway.config.json") }); + const config = createDefaultAppConfig({}); const corePort = await availablePort(); const coreEndpoint = `http://127.0.0.1:${corePort}`; const coreAuthToken = randomBytes(32).toString("base64url"); @@ -120,7 +120,7 @@ test("Fusion generates image and video through the local ai-gateway", { skip: !l authToken: coreAuthToken, baseUrl: coreEndpoint }); - await writeCoreGatewayConfig( + const coreGatewayConfig = await compileCoreGatewayConfig( config, randomBytes(24).toString("base64url"), randomBytes(24).toString("base64url"), @@ -128,12 +128,20 @@ test("Fusion generates image and video through the local ai-gateway", { skip: !l undefined, upstreamProxyUrl ); - enableGatewayDiagnostics(config.gateway.generatedConfigFile); + enableGatewayDiagnostics(coreGatewayConfig); const previousEntry = process.env.CCR_GATEWAY_ENTRY; process.env.CCR_GATEWAY_ENTRY = localGatewayEntry; try { - child = spawnGatewayProcess(config, upstreamProxyUrl, randomUUID(), coreAuthToken); + const spawnedGateway = spawnGatewayProcess( + config, + coreGatewayConfig, + upstreamProxyUrl, + randomUUID(), + coreAuthToken + ); + child = spawnedGateway.child; + await spawnedGateway.configAccepted; } finally { restoreEnv("CCR_GATEWAY_ENTRY", previousEntry); } @@ -212,8 +220,7 @@ function materializeProviderPlugins(templates, providerName, providerId, protoco return templates.map((template) => replacePlaceholders(template, replacements)); } -function enableGatewayDiagnostics(file) { - const generated = JSON.parse(readFileSync(file, "utf8")); +function enableGatewayDiagnostics(generated) { generated.logging = { accessLog: false, enabled: true, level: "info" }; const runtimeRoot = path.join(process.cwd(), ".test-dist", "core", "runtime"); for (const plugin of generated.plugins ?? []) { @@ -226,7 +233,6 @@ function enableGatewayDiagnostics(file) { server.args = [path.join(runtimeRoot, "media-tools-proxy-mcp.js")]; } } - writeFileSync(file, `${JSON.stringify(generated, null, 2)}\n`, { mode: 0o600 }); } function replacePlaceholders(value, replacements) { diff --git a/packages/core/test/integration/plugins/plugin-service.test.mjs b/packages/core/test/integration/plugins/plugin-service.test.mjs index 22e8d8ac..c7cfbfb3 100644 --- a/packages/core/test/integration/plugins/plugin-service.test.mjs +++ b/packages/core/test/integration/plugins/plugin-service.test.mjs @@ -64,7 +64,7 @@ module.exports = { }; `); - const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") }); + const config = createDefaultAppConfig(); config.Providers = [{ apiKey: "good-key", baseUrl: "https://good-provider.example/v1", @@ -154,7 +154,7 @@ module.exports = { }; `); - const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") }); + const config = createDefaultAppConfig(); config.gateway.enabled = false; config.plugins = [{ id: "broken", @@ -200,7 +200,7 @@ module.exports = { }; `); - const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") }); + const config = createDefaultAppConfig(); config.gateway.enabled = false; config.plugins = [{ id: "route-plugin", diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 4bfcb05a..8175b0cc 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -131,9 +131,7 @@ test("profile service preserves user statusLine when the active global Claude ta version: 1 }, null, 2)}\n`); - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.APIKEY = "ccr-profile-statusline-test"; config.APIKEYS = [{ createdAt: "2026-01-01T00:00:00.000Z", @@ -189,9 +187,7 @@ test("profile service does not overwrite invalid global Claude settings JSON", { smallFastModel: "", surface: "auto" }; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.APIKEY = "ccr-profile-invalid-settings-test"; config.APIKEYS = [{ createdAt: "2026-01-01T00:00:00.000Z", @@ -237,9 +233,7 @@ test("profile service does not rewrite Claude settings when only user-managed fi smallFastModel: "", surface: "auto" }; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.APIKEY = "ccr-profile-user-fields-test"; config.APIKEYS = [{ createdAt: "2026-01-01T00:00:00.000Z", @@ -287,9 +281,7 @@ test("profile service can exclude ZCode from automatic synchronization", async ( mkdirSync(path.dirname(configFile), { recursive: true }); writeFileSync(configFile, original); - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.profile.profiles = [ { agent: "zcode", @@ -339,9 +331,7 @@ test("profile service overwrites generated bin files without creating backups", writeFileSync(`${file}.ccr-original`, "old original\n"); } - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -445,9 +435,7 @@ test("Codex profile launcher bypasses middleware for Browser and Computer Use he ].join("\n")); chmodSync(fakeCodex, 0o700); - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.APIKEY = "ccr-browser-helper-test"; config.APIKEYS = [{ createdAt: "2026-01-01T00:00:00.000Z", @@ -521,9 +509,7 @@ test("Codex profile launcher bypasses middleware for Browser and Computer Use he test("profile service injects ToolHub MCP into Codex config", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "codex-toolhub-test"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -657,9 +643,7 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e test("profile service injects Context Archive MCP for managed Claude Code profile", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "managed-compact-claude"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -711,9 +695,7 @@ test("profile service injects Context Archive MCP for managed Claude Code profil test("profile service injects Context Archive MCP for managed Codex profile", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "managed-compact-codex"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -775,9 +757,7 @@ test("profile service injects Context Archive MCP for managed Codex profile", { test("profile service injects Context Archive MCP for managed Claude Code without ToolHub", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "context-archive-mcp-only"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -839,9 +819,7 @@ test("profile service writes a Grok CLI wrapper that points model discovery and mkdirSync(profileGrokHome, { recursive: true }); symlinkSync(path.join(sourceGrokHome, "config.toml"), profileGrokConfig); } - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -928,9 +906,7 @@ test("profile service writes a multi-model Kimi CLI home that points inference t "enabled = false", "" ].join("\n")); - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -1056,9 +1032,7 @@ test("profile service writes a multi-model Kimi CLI home that points inference t test("profile service writes a Pi config and wrapper that points inference to CCR", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "pi-gateway-test"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -1135,9 +1109,7 @@ test("profile service writes a Pi config and wrapper that points inference to CC test("profile service writes an OpenCode CLI wrapper and shared CLI/App config", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "opencode-gateway-test"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -1199,9 +1171,7 @@ test("profile service writes an OpenCode CLI wrapper and shared CLI/App config", test("profile service removes disabled and deleted OpenCode wrappers and API keys", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "opencode-cleanup-test"; - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", @@ -1300,9 +1270,7 @@ test("profile service clears stale Claude Code ToolHub artifacts when no gateway }, null, 2)}\n`); writeFileSync(staleToolHubMcpConfigFile, "{}\n"); - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(CONFIGDIR, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.Providers = []; config.virtualModelProfiles = []; config.profile.profiles = [ diff --git a/packages/core/test/unit/agents/context-archive.test.mjs b/packages/core/test/unit/agents/context-archive.test.mjs index b51da2ca..77c156c7 100644 --- a/packages/core/test/unit/agents/context-archive.test.mjs +++ b/packages/core/test/unit/agents/context-archive.test.mjs @@ -22,9 +22,7 @@ import { } from "@ccr/core/gateway/context-archive.ts"; function testConfig(overrides = {}) { - const config = createDefaultAppConfig({ - generatedConfigFile: "/tmp/ccr-context-archive-test-gateway.json" - }); + const config = createDefaultAppConfig(); return { ...config, APIKEY: "local-test-key", diff --git a/packages/core/test/unit/agents/kilo-profile-config.test.mjs b/packages/core/test/unit/agents/kilo-profile-config.test.mjs index 9dec095f..8b477a84 100644 --- a/packages/core/test/unit/agents/kilo-profile-config.test.mjs +++ b/packages/core/test/unit/agents/kilo-profile-config.test.mjs @@ -11,7 +11,7 @@ import { } from "@ccr/core/agents/kilo/profile-config.ts"; function testConfig(root) { - const config = createDefaultAppConfig({ generatedConfigFile: path.join(root, "gateway.config.json") }); + const config = createDefaultAppConfig(); config.Providers = [{ api_base_url: "https://example.test/v1", api_key: "provider-key", models: ["model-a", "model-b"], name: "Provider" }]; config.preferredProvider = "Provider"; config.gateway.host = "127.0.0.1"; diff --git a/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs index 404bf74f..cc28f151 100644 --- a/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs +++ b/packages/core/test/unit/agents/local-agent-provider-claude-code.test.mjs @@ -62,7 +62,7 @@ test("Core gateway config replaces imported Claude Code OAuth token with live ma access_token: "keychain-runtime-token", refresh_token: "keychain-refresh-token" }, async () => { - const config = createDefaultAppConfig({ generatedConfigFile: path.join(home, "config.json") }); + const config = createDefaultAppConfig(); config.providerPlugins = [ { auth: { diff --git a/packages/core/test/unit/agents/opencode-profile-config.test.mjs b/packages/core/test/unit/agents/opencode-profile-config.test.mjs index 1e5c10d3..ccb1c29a 100644 --- a/packages/core/test/unit/agents/opencode-profile-config.test.mjs +++ b/packages/core/test/unit/agents/opencode-profile-config.test.mjs @@ -17,7 +17,7 @@ import { } from "@ccr/core/agents/opencode/app-launch.ts"; function testConfig(root) { - const config = createDefaultAppConfig({ generatedConfigFile: path.join(root, "gateway.config.json") }); + const config = createDefaultAppConfig(); config.Providers = [{ api_base_url: "https://example.test/v1", api_key: "provider-key", models: ["model-a", "model-b"], name: "Provider" }]; config.preferredProvider = "Provider"; config.gateway.host = "127.0.0.1"; diff --git a/packages/core/test/unit/agents/pi-profile-config.test.mjs b/packages/core/test/unit/agents/pi-profile-config.test.mjs index 5109f3ea..85d9c1e3 100644 --- a/packages/core/test/unit/agents/pi-profile-config.test.mjs +++ b/packages/core/test/unit/agents/pi-profile-config.test.mjs @@ -9,7 +9,7 @@ import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; test("Pi profile config writes a CCR OpenAI Responses provider", () => { const root = mkdtempSync(path.join(os.tmpdir(), "ccr-pi-profile-")); try { - const config = createDefaultAppConfig({ generatedConfigFile: path.join(root, "gateway.config.json") }); + const config = createDefaultAppConfig(); config.gateway.host = "0.0.0.0"; config.gateway.port = 3459; config.Providers = [ diff --git a/packages/core/test/unit/agents/plugin-permissions.test.mjs b/packages/core/test/unit/agents/plugin-permissions.test.mjs index 2d212a46..5a7a96a1 100644 --- a/packages/core/test/unit/agents/plugin-permissions.test.mjs +++ b/packages/core/test/unit/agents/plugin-permissions.test.mjs @@ -314,9 +314,7 @@ async function startWithWarnings(config) { } function baseConfig(dir) { - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(dir, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.gateway.enabled = false; config.plugins = []; return config; diff --git a/packages/core/test/unit/agents/plugin-stop-reason.test.mjs b/packages/core/test/unit/agents/plugin-stop-reason.test.mjs index 4cadcb29..a43ae679 100644 --- a/packages/core/test/unit/agents/plugin-stop-reason.test.mjs +++ b/packages/core/test/unit/agents/plugin-stop-reason.test.mjs @@ -46,9 +46,7 @@ test("gateway restart reports disabled reason to removed plugin stop hooks", { s }); function configWithPlugin(dir, pluginFile, enabled) { - const config = createDefaultAppConfig({ - generatedConfigFile: path.join(dir, "gateway.config.json") - }); + const config = createDefaultAppConfig(); config.gateway.enabled = false; config.plugins = [{ enabled, diff --git a/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs b/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs index 279f2aec..6b301163 100644 --- a/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs +++ b/packages/core/test/unit/agents/provider-plugin-runtime-identity.test.mjs @@ -7,7 +7,7 @@ import { prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream test("provider plugins use compiled runtime and capability identities", async () => { const unchangedPlugin = { key: "unscoped-plugin" }; - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-provider-plugin-runtime-identity.json" }); + const config = createDefaultAppConfig(); config.providerPlugins = [ { key: "single-protocol-plugin", @@ -64,7 +64,7 @@ test("provider plugins use compiled runtime and capability identities", async () }); test("Codex OAuth plugins retain the default base URL after runtime identity normalization", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-codex-oauth-runtime-identity.json" }); + const config = createDefaultAppConfig(); config.providerPlugins = [ { codexOauth: {}, diff --git a/packages/core/test/unit/config/config-repository-backup-deduplication.test.mjs b/packages/core/test/unit/config/config-repository-backup-deduplication.test.mjs new file mode 100644 index 00000000..832e6e44 --- /dev/null +++ b/packages/core/test/unit/config/config-repository-backup-deduplication.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("repeated legacy JSON cleanup failures keep one content-addressed backup", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `config-repository-backup-deduplication-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE + } = await import("@ccr/core/config/constants.ts"); + const { + ConfigRepository + } = await import("@ccr/core/config/config-repository.ts"); + const { + createBetterSqliteDatabase + } = await import("@ccr/core/storage/sqlite-native.ts"); + + mkdirSync(path.dirname(LEGACY_ACTIVE_CONFIG_FILE), { recursive: true }); + const legacyContent = `${JSON.stringify({ PORT: 3456, Providers: [] }, null, 2)}\n`; + writeFileSync(LEGACY_ACTIVE_CONFIG_FILE, legacyContent, "utf8"); + seedHistoricalDuplicateBackups( + createBetterSqliteDatabase, + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE, + legacyContent + ); + + let removalAttempts = 0; + const failingRepository = new ConfigRepository(APP_CONFIG_DB_FILE, { + removeFile() { + removalAttempts += 1; + throw new Error("simulated cleanup failure"); + } + }); + await failingRepository.archiveLegacyJsonConfigFiles([LEGACY_ACTIVE_CONFIG_FILE]); + await failingRepository.archiveLegacyJsonConfigFiles([LEGACY_ACTIVE_CONFIG_FILE]); + + assert.equal(removalAttempts, 3); + assert.equal(existsSync(LEGACY_ACTIVE_CONFIG_FILE), true); + assert.equal(readBackupCount( + createBetterSqliteDatabase, + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE + ), 1); + assert.equal(readCleanupCount(createBetterSqliteDatabase, APP_CONFIG_DB_FILE), 1); + + const retryRepository = new ConfigRepository(APP_CONFIG_DB_FILE); + await retryRepository.readAppConfig(); + + assert.equal(existsSync(LEGACY_ACTIVE_CONFIG_FILE), false); + assert.equal(readBackupCount( + createBetterSqliteDatabase, + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE + ), 1); + assert.equal(readCleanupCount(createBetterSqliteDatabase, APP_CONFIG_DB_FILE), 0); +}); + +function seedHistoricalDuplicateBackups(createDatabase, dbFile, sourceFile, content) { + mkdirSync(path.dirname(dbFile), { recursive: true }); + const database = createDatabase(dbFile); + const sha256 = createHash("sha256").update(content).digest("hex"); + try { + database.exec(` + CREATE TABLE config_schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE legacy_storage_backups ( + id TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_kind TEXT NOT NULL, + sha256 TEXT NOT NULL, + content BLOB NOT NULL, + archived_at TEXT NOT NULL + ); + `); + const insert = database.prepare(` + INSERT INTO legacy_storage_backups ( + id, source_path, source_kind, sha256, content, archived_at + ) VALUES (?, ?, 'legacy-json-config', ?, ?, ?) + `); + insert.run("historical-backup-1", path.resolve(sourceFile), sha256, Buffer.from(content), new Date(0).toISOString()); + insert.run("historical-backup-2", path.resolve(sourceFile), sha256, Buffer.from(content), new Date(1).toISOString()); + } finally { + database.close(); + } +} + +function readBackupCount(createDatabase, file, sourcePath) { + return withReadonlyDatabase(createDatabase, file, (database) => database.prepare(` + SELECT COUNT(*) AS count + FROM legacy_storage_backups + WHERE source_kind = 'legacy-json-config' AND source_path = ? + `).get(path.resolve(sourcePath)).count); +} + +function readCleanupCount(createDatabase, file) { + return withReadonlyDatabase(createDatabase, file, (database) => database.prepare(` + SELECT COUNT(*) AS count + FROM legacy_storage_cleanup + WHERE source_kind = 'legacy-json-config' + `).get().count); +} + +function withReadonlyDatabase(createDatabase, file, operation) { + const database = createDatabase(file, { + fileMustExist: true, + readonly: true + }); + try { + return operation(database); + } finally { + database.close(); + } +} diff --git a/packages/core/test/unit/config/config-repository-cleanup-retry.test.mjs b/packages/core/test/unit/config/config-repository-cleanup-retry.test.mjs new file mode 100644 index 00000000..d890525d --- /dev/null +++ b/packages/core/test/unit/config/config-repository-cleanup-retry.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("legacy SQLite cleanup retries even when an old completion marker exists", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `config-repository-cleanup-retry-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + LEGACY_API_KEYS_DB_FILE + } = await import("@ccr/core/config/constants.ts"); + const { + ConfigRepository + } = await import("@ccr/core/config/config-repository.ts"); + const { + createBetterSqliteDatabase + } = await import("@ccr/core/storage/sqlite-native.ts"); + + createTargetDatabaseWithOldCompletionMarker(createBetterSqliteDatabase, APP_CONFIG_DB_FILE); + createApiKeyDatabase(createBetterSqliteDatabase, LEGACY_API_KEYS_DB_FILE); + const orphanWalFile = `${LEGACY_API_KEYS_DB_FILE}-wal`; + writeFileSync(orphanWalFile, "legacy sidecar content\n", "utf8"); + + const failedRemovals = []; + const firstRepository = new ConfigRepository(APP_CONFIG_DB_FILE, { + removeFile(file) { + failedRemovals.push(file); + if (file === path.resolve(LEGACY_API_KEYS_DB_FILE)) { + rmSync(file, { force: true }); + return; + } + throw new Error("simulated cleanup failure"); + } + }); + const firstRead = await firstRepository.listApiKeys(); + + assert.deepEqual(firstRead.map((apiKey) => apiKey.id), ["legacy-key"]); + assert.equal(existsSync(LEGACY_API_KEYS_DB_FILE), false); + assert.equal(failedRemovals.includes(path.resolve(orphanWalFile)), true); + assert.equal(existsSync(orphanWalFile), true); + assert.equal(readMigration(createBetterSqliteDatabase, APP_CONFIG_DB_FILE), undefined); + assert.equal(readBackupCount( + createBetterSqliteDatabase, + APP_CONFIG_DB_FILE, + LEGACY_API_KEYS_DB_FILE + ), 1); + + const retryRepository = new ConfigRepository(APP_CONFIG_DB_FILE); + const retryRead = await retryRepository.listApiKeys(); + + assert.deepEqual(retryRead.map((apiKey) => apiKey.id), ["legacy-key"]); + assert.equal(existsSync(LEGACY_API_KEYS_DB_FILE), false); + assert.equal(existsSync(orphanWalFile), false); + assert.equal(readMigration(createBetterSqliteDatabase, APP_CONFIG_DB_FILE), "unified-config-store-v1"); + assert.equal(readBackupCount( + createBetterSqliteDatabase, + APP_CONFIG_DB_FILE, + LEGACY_API_KEYS_DB_FILE + ), 1); + assert.equal(readCleanupCount(createBetterSqliteDatabase, APP_CONFIG_DB_FILE), 0); +}); + +function createTargetDatabaseWithOldCompletionMarker(createDatabase, file) { + mkdirSync(path.dirname(file), { recursive: true }); + const database = createDatabase(file); + try { + database.exec(` + CREATE TABLE config_schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '' + ) + `); + database.prepare(` + INSERT INTO config_schema_migrations (id, applied_at, details_json) + VALUES ('unified-config-store-v1', ?, '') + `).run(new Date(0).toISOString()); + } finally { + database.close(); + } +} + +function createApiKeyDatabase(createDatabase, file) { + mkdirSync(path.dirname(file), { recursive: true }); + const database = createDatabase(file); + try { + database.exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + encrypted_key TEXT NOT NULL, + encryption TEXT NOT NULL DEFAULT 'plain', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL DEFAULT '', + limits_json TEXT NOT NULL DEFAULT '' + ) + `); + database.prepare(` + INSERT INTO api_keys ( + id, name, encrypted_key, encryption, created_at, expires_at, limits_json + ) VALUES ('legacy-key', '', 'sk-legacy', 'plain', ?, '', '') + `).run(new Date(0).toISOString()); + } finally { + database.close(); + } +} + +function readMigration(createDatabase, file) { + return withReadonlyDatabase(createDatabase, file, (database) => database.prepare(` + SELECT id + FROM config_schema_migrations + WHERE id = 'unified-config-store-v1' + `).get()?.id); +} + +function readBackupCount(createDatabase, file, sourcePath) { + return withReadonlyDatabase(createDatabase, file, (database) => database.prepare(` + SELECT COUNT(*) AS count + FROM legacy_storage_backups + WHERE source_kind = 'legacy-sqlite-config' AND source_path = ? + `).get(path.resolve(sourcePath)).count); +} + +function readCleanupCount(createDatabase, file) { + return withReadonlyDatabase(createDatabase, file, (database) => database.prepare(` + SELECT COUNT(*) AS count + FROM legacy_storage_cleanup + WHERE source_kind = 'legacy-sqlite-config' + `).get().count); +} + +function withReadonlyDatabase(createDatabase, file, operation) { + const database = createDatabase(file, { + fileMustExist: true, + readonly: true + }); + try { + return operation(database); + } finally { + database.close(); + } +} diff --git a/packages/core/test/unit/config/config-repository-existing-data-migration.test.mjs b/packages/core/test/unit/config/config-repository-existing-data-migration.test.mjs new file mode 100644 index 00000000..463ed22e --- /dev/null +++ b/packages/core/test/unit/config/config-repository-existing-data-migration.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("legacy API keys are merged without replacing existing target keys", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `config-repository-existing-data-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + LEGACY_API_KEYS_DB_FILE + } = await import("@ccr/core/config/constants.ts"); + const { + createBetterSqliteDatabase + } = await import("@ccr/core/storage/sqlite-native.ts"); + + createApiKeyDatabase(createBetterSqliteDatabase, APP_CONFIG_DB_FILE, { + id: "existing-key", + key: "sk-existing" + }); + createApiKeyDatabase(createBetterSqliteDatabase, LEGACY_API_KEYS_DB_FILE, { + id: "legacy-key", + key: "sk-legacy" + }); + + const { + loadPersistedApiKeys + } = await import("@ccr/core/config/config-repository.ts"); + const apiKeys = await loadPersistedApiKeys(); + assert.deepEqual(apiKeys.map((apiKey) => apiKey.id), [ + "existing-key", + "legacy-key" + ]); + assert.equal(existsSync(LEGACY_API_KEYS_DB_FILE), false); + + const database = createBetterSqliteDatabase(APP_CONFIG_DB_FILE, { + fileMustExist: true, + readonly: true + }); + try { + const migration = database.prepare(` + SELECT id + FROM config_schema_migrations + WHERE id = 'unified-config-store-v1' + `).get(); + assert.equal(migration?.id, "unified-config-store-v1"); + } finally { + database.close(); + } +}); + +function createApiKeyDatabase(createDatabase, file, apiKey) { + mkdirSync(path.dirname(file), { recursive: true }); + const database = createDatabase(file); + try { + database.exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + encrypted_key TEXT NOT NULL, + encryption TEXT NOT NULL DEFAULT 'plain', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL DEFAULT '', + limits_json TEXT NOT NULL DEFAULT '' + ) + `); + database.prepare(` + INSERT INTO api_keys ( + id, name, encrypted_key, encryption, created_at, expires_at, limits_json + ) VALUES (?, '', ?, 'plain', ?, '', '') + `).run(apiKey.id, apiKey.key, new Date(0).toISOString()); + } finally { + database.close(); + } +} diff --git a/packages/core/test/unit/config/config-repository-migration.test.mjs b/packages/core/test/unit/config/config-repository-migration.test.mjs new file mode 100644 index 00000000..d55e8f42 --- /dev/null +++ b/packages/core/test/unit/config/config-repository-migration.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("unreadable legacy SQLite sources are preserved for a future migration retry", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `config-repository-migration-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + LEGACY_API_KEYS_DB_FILE + } = await import("@ccr/core/config/constants.ts"); + mkdirSync(path.dirname(LEGACY_API_KEYS_DB_FILE), { recursive: true }); + writeFileSync(LEGACY_API_KEYS_DB_FILE, "not a SQLite database"); + + const { + loadPersistedApiKeys + } = await import("@ccr/core/config/config-repository.ts"); + assert.deepEqual(await loadPersistedApiKeys(), []); + assert.equal(existsSync(LEGACY_API_KEYS_DB_FILE), true); + + const { + createBetterSqliteDatabase + } = await import("@ccr/core/storage/sqlite-native.ts"); + const database = createBetterSqliteDatabase(APP_CONFIG_DB_FILE, { + fileMustExist: true, + readonly: true + }); + try { + const migration = database.prepare(` + SELECT id + FROM config_schema_migrations + WHERE id = 'unified-config-store-v1' + `).get(); + const backup = database.prepare(` + SELECT source_path + FROM legacy_storage_backups + WHERE source_path = ? + `).get(path.resolve(LEGACY_API_KEYS_DB_FILE)); + assert.equal(migration, undefined); + assert.equal(backup, undefined); + } finally { + database.close(); + } +}); diff --git a/packages/core/test/unit/config/legacy-json-preservation.test.mjs b/packages/core/test/unit/config/legacy-json-preservation.test.mjs new file mode 100644 index 00000000..fffdbf9f --- /dev/null +++ b/packages/core/test/unit/config/legacy-json-preservation.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("malformed legacy JSON is preserved when default config is persisted", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `legacy-json-preservation-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + LEGACY_ACTIVE_CONFIG_FILE + } = await import("@ccr/core/config/constants.ts"); + mkdirSync(path.dirname(LEGACY_ACTIVE_CONFIG_FILE), { recursive: true }); + const malformedConfig = "{\n \"PORT\": 9999,\n"; + writeFileSync(LEGACY_ACTIVE_CONFIG_FILE, malformedConfig, "utf8"); + + const { loadAppConfig } = await import("@ccr/core/config/config.ts"); + const config = await loadAppConfig(); + + assert.equal(config.PORT, 3456); + assert.equal(existsSync(APP_CONFIG_DB_FILE), true); + assert.equal(existsSync(LEGACY_ACTIVE_CONFIG_FILE), true); + assert.equal(readFileSync(LEGACY_ACTIVE_CONFIG_FILE, "utf8"), malformedConfig); +}); diff --git a/packages/core/test/unit/config/legacy-json-selected-source.test.mjs b/packages/core/test/unit/config/legacy-json-selected-source.test.mjs new file mode 100644 index 00000000..f04588ef --- /dev/null +++ b/packages/core/test/unit/config/legacy-json-selected-source.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("only the successfully parsed legacy JSON source is archived", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `legacy-json-selected-source-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE + } = await import("@ccr/core/config/constants.ts"); + mkdirSync(path.dirname(LEGACY_ACTIVE_CONFIG_FILE), { recursive: true }); + mkdirSync(path.dirname(LEGACY_WINDOWS_CONFIG_FILE), { recursive: true }); + + const malformedConfig = "{\n \"PORT\": 9999,\n"; + writeFileSync(LEGACY_ACTIVE_CONFIG_FILE, malformedConfig, "utf8"); + writeFileSync(LEGACY_WINDOWS_CONFIG_FILE, JSON.stringify({ PORT: 4567 }), "utf8"); + + const { loadAppConfig } = await import("@ccr/core/config/config.ts"); + const config = await loadAppConfig(); + + assert.equal(config.PORT, 4567); + assert.equal(existsSync(LEGACY_ACTIVE_CONFIG_FILE), true); + assert.equal(readFileSync(LEGACY_ACTIVE_CONFIG_FILE, "utf8"), malformedConfig); + assert.equal(existsSync(LEGACY_WINDOWS_CONFIG_FILE), false); +}); diff --git a/packages/core/test/unit/config/onboarding-state.test.mjs b/packages/core/test/unit/config/onboarding-state.test.mjs new file mode 100644 index 00000000..cf4e4b83 --- /dev/null +++ b/packages/core/test/unit/config/onboarding-state.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +test("onboarding state falls back to the legacy marker when the config database cannot be read", async () => { + const testRoot = path.join( + process.env.CCR_INTERNAL_HOME_DIR, + `onboarding-state-fallback-${process.pid}` + ); + process.env.CCR_INTERNAL_HOME_DIR = path.join(testRoot, "home"); + process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(testRoot, "app-data"); + process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(testRoot, "user-data"); + + const { + APP_CONFIG_DB_FILE, + ONBOARDING_FINISHED_FILE + } = await import("@ccr/core/config/constants.ts"); + mkdirSync(path.dirname(APP_CONFIG_DB_FILE), { recursive: true }); + writeFileSync(APP_CONFIG_DB_FILE, "not a SQLite database", "utf8"); + writeFileSync(ONBOARDING_FINISHED_FILE, "", "utf8"); + + const { loadOnboardingFinished } = await import("@ccr/core/config/onboarding-state.ts"); + assert.equal(await loadOnboardingFinished(), true); + + rmSync(ONBOARDING_FINISHED_FILE); + assert.equal(await loadOnboardingFinished(), false); +}); diff --git a/packages/core/test/unit/config/theme-preference.test.mjs b/packages/core/test/unit/config/theme-preference.test.mjs index 7da418e7..ba5e5d5b 100644 --- a/packages/core/test/unit/config/theme-preference.test.mjs +++ b/packages/core/test/unit/config/theme-preference.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store.ts"; +import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/config-repository.ts"; import { loadAppConfig, saveAppConfig, saveAppThemePreference } from "@ccr/core/config/config.ts"; test("theme preference persistence changes only the theme field", async () => { diff --git a/packages/core/test/unit/gateway/gateway-billing-sync.test.mjs b/packages/core/test/unit/gateway/gateway-billing-sync.test.mjs index e517aac7..610c7fdf 100644 --- a/packages/core/test/unit/gateway/gateway-billing-sync.test.mjs +++ b/packages/core/test/unit/gateway/gateway-billing-sync.test.mjs @@ -5,7 +5,7 @@ import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config- import { rawTraceSyncHeader, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared.ts"; test("core gateway disables the full-trace billing webhook without disabling raw-trace observability", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated-config.json" }); + const config = createDefaultAppConfig(); config.gateway.host = "0.0.0.0"; config.gateway.port = 4567; config.observability.requestLogs = true; @@ -44,7 +44,7 @@ test("core gateway disables the full-trace billing webhook without disabling raw }); test("Codex OAuth providers remove unsupported Responses request fields", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated-config.json" }); + const config = createDefaultAppConfig(); config.providerPlugins = [{ codexOauth: {}, enabled: true, diff --git a/packages/core/test/unit/gateway/gateway-media-config.test.mjs b/packages/core/test/unit/gateway/gateway-media-config.test.mjs index 14aeb065..ccf15d03 100644 --- a/packages/core/test/unit/gateway/gateway-media-config.test.mjs +++ b/packages/core/test/unit/gateway/gateway-media-config.test.mjs @@ -5,7 +5,7 @@ import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config- import { shouldRunGatewayRuntime } from "@ccr/core/gateway/core-runtime/supervisor.ts"; test("media tools start their internal gateway runtime when the public gateway is disabled", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.gateway.enabled = false; config.proxy.enabled = false; config.mediaTools.enabled = true; @@ -14,7 +14,7 @@ test("media tools start their internal gateway runtime when the public gateway i }); test("core gateway compiles media capabilities and provider plugin aliases for credentials", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.Providers = [{ api_base_url: "https://chat.example/v1", capabilities: [ diff --git a/packages/core/test/unit/gateway/gateway-runtime-change.test.mjs b/packages/core/test/unit/gateway/gateway-runtime-change.test.mjs index d1df79f7..5aab1111 100644 --- a/packages/core/test/unit/gateway/gateway-runtime-change.test.mjs +++ b/packages/core/test/unit/gateway/gateway-runtime-change.test.mjs @@ -5,8 +5,8 @@ import { mediaToolsConfigFromRawForTest, virtualModelProfileFromRawForTest } fro import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change.ts"; test("ToolHub config changes restart the gateway runtime", () => { - const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); - const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const previous = createDefaultAppConfig(); + const next = createDefaultAppConfig(); next.toolHub = { ...next.toolHub, enabled: true, @@ -27,8 +27,8 @@ test("ToolHub config changes restart the gateway runtime", () => { }); test("media tool policy changes restart the gateway runtime", () => { - const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); - const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const previous = createDefaultAppConfig(); + const next = createDefaultAppConfig(); next.mediaTools.enabled = true; assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true); @@ -78,8 +78,8 @@ test("legacy virtual model tool loop limits are removed from application config" }); test("upstream proxy config changes restart the gateway runtime", () => { - const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); - const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const previous = createDefaultAppConfig(); + const next = createDefaultAppConfig(); next.proxy.upstream = { custom: { password: "secret", @@ -102,16 +102,16 @@ test("raw trace observability config changes restart the gateway runtime", () => ]; for (const mutate of mutations) { - const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); - const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const previous = createDefaultAppConfig(); + const next = createDefaultAppConfig(); mutate(next); assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), true); } }); test("main-process-only observability changes do not restart the gateway runtime", () => { - const previous = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); - const next = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const previous = createDefaultAppConfig(); + const next = createDefaultAppConfig(); next.observability.requestLogSuccessSampleRate = 0.25; assert.equal(shouldRestartGatewayForRuntimeConfigChange(previous, next), false); diff --git a/packages/core/test/unit/gateway/gateway-status.test.mjs b/packages/core/test/unit/gateway/gateway-status.test.mjs index 65c01f0a..f3cec500 100644 --- a/packages/core/test/unit/gateway/gateway-status.test.mjs +++ b/packages/core/test/unit/gateway/gateway-status.test.mjs @@ -1,12 +1,22 @@ import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; +import { + configRepository, + deletePersistedRuntimeState, + loadPersistedRuntimeState, + replacePersistedRuntimeState +} from "@ccr/core/config/config-repository.ts"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; import { gatewayService } from "@ccr/core/gateway/service.ts"; test("gateway start persists preflight validation failures for status polling", async () => { await gatewayService.stop(); - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.gateway.coreHost = "0.0.0.0"; const startStatus = await gatewayService.start(config); @@ -18,3 +28,262 @@ test("gateway start persists preflight validation failures for status polling", await gatewayService.stop(); }); + +test("gateway stop preserves a runtime marker that this service instance does not own", async () => { + await gatewayService.stop(); + const marker = { + pid: 12345, + runtimeId: "previous-runtime", + startedAt: new Date(0).toISOString() + }; + await replacePersistedRuntimeState("gateway", marker); + + try { + await gatewayService.stop(); + assert.deepEqual(await loadPersistedRuntimeState("gateway"), marker); + } finally { + await deletePersistedRuntimeState("gateway"); + } +}); + +test("gateway start observes an exit that occurs while the runtime marker is being written", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-gateway-early-exit-test-")); + const gatewayEntry = path.join(dir, "gateway-entry.cjs"); + const gatewayEntryLoaded = path.join(dir, "gateway-entry-loaded"); + const previousGatewayEntry = process.env.CCR_GATEWAY_ENTRY; + const previousGatewayEntryLoaded = process.env.CCR_GATEWAY_EARLY_EXIT_SENTINEL; + const replaceRuntimeState = configRepository.replaceRuntimeState; + writeFileSync( + gatewayEntry, + [ + 'const { writeFileSync } = require("node:fs");', + 'writeFileSync(process.env.CCR_GATEWAY_EARLY_EXIT_SENTINEL, "loaded\\n");', + "process.exit(23);", + "" + ].join("\n"), + "utf8" + ); + + try { + await gatewayService.stop(); + process.env.CCR_GATEWAY_ENTRY = gatewayEntry; + process.env.CCR_GATEWAY_EARLY_EXIT_SENTINEL = gatewayEntryLoaded; + configRepository.replaceRuntimeState = async function (...args) { + await waitForFile(gatewayEntryLoaded); + await delay(100); + return replaceRuntimeState.apply(this, args); + }; + + const config = createDefaultAppConfig(); + config.gateway.enabled = true; + config.gateway.corePort = 0; + config.gateway.port = 0; + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "test-provider-key", + id: "early-exit-provider", + models: ["early-exit-model"], + name: "Early Exit Provider", + type: "openai_chat_completions" + }]; + + const status = await gatewayService.start(config); + + assert.equal(status.state, "error"); + assert.match(status.lastError ?? "", /Core gateway exited with 23/); + assert.equal(await loadPersistedRuntimeState("gateway"), undefined); + } finally { + configRepository.replaceRuntimeState = replaceRuntimeState; + if (previousGatewayEntry === undefined) { + delete process.env.CCR_GATEWAY_ENTRY; + } else { + process.env.CCR_GATEWAY_ENTRY = previousGatewayEntry; + } + if (previousGatewayEntryLoaded === undefined) { + delete process.env.CCR_GATEWAY_EARLY_EXIT_SENTINEL; + } else { + process.env.CCR_GATEWAY_EARLY_EXIT_SENTINEL = previousGatewayEntryLoaded; + } + await gatewayService.stop(); + await deletePersistedRuntimeState("gateway"); + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("gateway start waits for matching runtime health before reporting running", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-gateway-readiness-test-")); + const gatewayEntry = path.join(dir, "gateway-entry.cjs"); + const readySentinel = path.join(dir, "gateway-ready"); + const previousGatewayEntry = process.env.CCR_GATEWAY_ENTRY; + const previousReadyDelay = process.env.CCR_GATEWAY_READY_DELAY_MS; + const previousReadySentinel = process.env.CCR_GATEWAY_READY_SENTINEL; + writeFileSync(gatewayEntry, delayedHealthyGatewayEntry(), "utf8"); + + try { + await gatewayService.stop(); + process.env.CCR_GATEWAY_ENTRY = gatewayEntry; + process.env.CCR_GATEWAY_READY_DELAY_MS = "250"; + process.env.CCR_GATEWAY_READY_SENTINEL = readySentinel; + + const config = gatewayTestConfig(await findAvailablePort()); + const startedAt = Date.now(); + const status = await gatewayService.start(config); + const elapsedMs = Date.now() - startedAt; + + assert.equal(status.state, "running"); + assert.equal(existsSync(readySentinel), true); + assert.ok(elapsedMs >= 200, `gateway start returned before delayed health was ready (${elapsedMs}ms)`); + assert.match(String((await loadPersistedRuntimeState("gateway"))?.runtimeId), /.+/); + } finally { + restoreEnv("CCR_GATEWAY_ENTRY", previousGatewayEntry); + restoreEnv("CCR_GATEWAY_READY_DELAY_MS", previousReadyDelay); + restoreEnv("CCR_GATEWAY_READY_SENTINEL", previousReadySentinel); + await gatewayService.stop(); + await deletePersistedRuntimeState("gateway"); + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("gateway start completes the IPC and health handshake with the bundled runtime", async () => { + const previousGatewayEntry = process.env.CCR_GATEWAY_ENTRY; + try { + await gatewayService.stop(); + delete process.env.CCR_GATEWAY_ENTRY; + const status = await gatewayService.start(gatewayTestConfig(await findAvailablePort())); + assert.equal(status.state, "running", status.lastError); + + const marker = await loadPersistedRuntimeState("gateway"); + const healthResponse = await fetch(new URL("/health", status.coreEndpoint)); + const health = await healthResponse.json(); + + assert.equal(healthResponse.status, 200); + assert.equal(health.status, "ok"); + assert.equal(health.runtimeId, marker?.runtimeId); + } finally { + restoreEnv("CCR_GATEWAY_ENTRY", previousGatewayEntry); + await gatewayService.stop(); + await deletePersistedRuntimeState("gateway"); + } +}); + +test("gateway start rejects healthy endpoints owned by a different runtime", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-gateway-runtime-identity-test-")); + const gatewayEntry = path.join(dir, "gateway-entry.cjs"); + const previousGatewayEntry = process.env.CCR_GATEWAY_ENTRY; + writeFileSync(gatewayEntry, foreignRuntimeGatewayEntry(), "utf8"); + + try { + await gatewayService.stop(); + process.env.CCR_GATEWAY_ENTRY = gatewayEntry; + + const status = await gatewayService.start(gatewayTestConfig(await findAvailablePort())); + + assert.equal(status.state, "error"); + assert.match(status.lastError ?? "", /owned by a different runtime/); + assert.equal(await loadPersistedRuntimeState("gateway"), undefined); + } finally { + restoreEnv("CCR_GATEWAY_ENTRY", previousGatewayEntry); + await gatewayService.stop(); + await deletePersistedRuntimeState("gateway"); + rmSync(dir, { force: true, recursive: true }); + } +}); + +function gatewayTestConfig(corePort) { + const config = createDefaultAppConfig(); + config.gateway.enabled = true; + config.gateway.corePort = corePort; + config.gateway.port = 0; + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "test-provider-key", + id: "readiness-provider", + models: ["readiness-model"], + name: "Readiness Provider", + type: "openai_chat_completions" + }]; + return config; +} + +function delayedHealthyGatewayEntry() { + return [ + 'const { writeFileSync } = require("node:fs");', + 'const { createServer } = require("node:http");', + "const delayMs = Number(process.env.CCR_GATEWAY_READY_DELAY_MS || 0);", + "setTimeout(() => {", + " const server = createServer((request, response) => {", + " if (request.url === '/health') {", + " response.writeHead(200, { 'content-type': 'application/json' });", + " response.end(JSON.stringify({", + " runtimeId: process.env.CCR_GATEWAY_RUNTIME_ID,", + " status: 'ok'", + " }));", + " return;", + " }", + " response.writeHead(404);", + " response.end();", + " });", + " server.listen(Number(process.env.PORT), process.env.HOST, () => {", + " writeFileSync(process.env.CCR_GATEWAY_READY_SENTINEL, 'ready\\n');", + " });", + "}, delayMs);", + "" + ].join("\n"); +} + +function foreignRuntimeGatewayEntry() { + return [ + 'const { createServer } = require("node:http");', + "createServer((request, response) => {", + " if (request.url === '/health') {", + " response.writeHead(200, { 'content-type': 'application/json' });", + " response.end(JSON.stringify({ runtimeId: 'foreign-runtime', status: 'ok' }));", + " return;", + " }", + " response.writeHead(404);", + " response.end();", + "}).listen(Number(process.env.PORT), process.env.HOST);", + "" + ].join("\n"); +} + +async function findAvailablePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + server.close(() => { + if (port) { + resolve(port); + } else { + reject(new Error("Failed to allocate a gateway test port.")); + } + }); + }); + }); +} + +function restoreEnv(key, value) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForFile(file, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(file)) { + return; + } + await delay(10); + } + assert.fail(`Timed out waiting for gateway entry sentinel: ${file}`); +} diff --git a/packages/core/test/unit/mcp/toolhub-browser-automation-config.test.mjs b/packages/core/test/unit/mcp/toolhub-browser-automation-config.test.mjs index 25e0aa21..002a4d40 100644 --- a/packages/core/test/unit/mcp/toolhub-browser-automation-config.test.mjs +++ b/packages/core/test/unit/mcp/toolhub-browser-automation-config.test.mjs @@ -22,7 +22,7 @@ test("ToolHub runtime candidates include the clean Core test build", () => { }); test("Media tools are a Fusion MCP backend independent from ToolHub", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.gateway.host = "0.0.0.0"; config.mediaTools.enabled = true; config.mediaTools.jobTimeoutMs = 600000; @@ -69,7 +69,7 @@ test("Media tools are a Fusion MCP backend independent from ToolHub", () => { }); test("ToolHub does not absorb Fusion media tools", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.toolHub.enabled = true; config.mediaTools.enabled = true; @@ -77,7 +77,7 @@ test("ToolHub does not absorb Fusion media tools", () => { }); test("Core Gateway registers media tools directly for Fusion models", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.mediaTools.enabled = true; config.toolHub.enabled = true; @@ -91,7 +91,7 @@ test("Core Gateway registers media tools directly for Fusion models", async () = }); test("Core Gateway compiles one profile for each configured Fusion media model", async () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.mediaTools.enabled = true; config.Providers = [ { models: ["base-model"], name: "Provider" }, @@ -127,7 +127,7 @@ test("Core Gateway compiles one profile for each configured Fusion media model", }); test("ToolHub runtime includes the built-in browser automation backend", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.toolHub = { ...config.toolHub, browserAutomation: true, @@ -154,7 +154,7 @@ test("ToolHub runtime includes the built-in browser automation backend", () => { }); test("ToolHub browser automation backend uses a connectable loopback host", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.gateway.host = "0.0.0.0"; config.toolHub = { ...config.toolHub, @@ -177,7 +177,7 @@ test("ToolHub browser automation backend uses a connectable loopback host", () = }); test("ToolHub runtime skips built-in browser automation until enabled", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.toolHub = { ...config.toolHub, browserAutomation: false, diff --git a/packages/core/test/unit/observability/raw-trace-sync.test.mjs b/packages/core/test/unit/observability/raw-trace-sync.test.mjs index c93c88f0..21786925 100644 --- a/packages/core/test/unit/observability/raw-trace-sync.test.mjs +++ b/packages/core/test/unit/observability/raw-trace-sync.test.mjs @@ -875,7 +875,7 @@ test("fallback raw bundles keep unique bundle ids while sharing the logical requ }); function createConfig() { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated-config.json" }); + const config = createDefaultAppConfig(); config.observability.requestLogs = true; config.observability.requestLogBodyCapture = "all"; config.observability.requestLogSuccessSampleRate = 1; diff --git a/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs b/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs index 6a553076..0d178102 100644 --- a/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs +++ b/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs @@ -14,7 +14,7 @@ function claudeProfileConfig() { scope: "ccr", surface: "cli" }; - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.APIKEY = "profile-token"; config.APIKEYS = [ { diff --git a/packages/core/test/unit/proxy/proxy-upstream.test.mjs b/packages/core/test/unit/proxy/proxy-upstream.test.mjs index 8b82cac6..58721b70 100644 --- a/packages/core/test/unit/proxy/proxy-upstream.test.mjs +++ b/packages/core/test/unit/proxy/proxy-upstream.test.mjs @@ -9,7 +9,7 @@ import { } from "@ccr/core/proxy/system-proxy.ts"; test("custom upstream proxy config creates authenticated proxy URLs", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.proxy.upstream = { custom: { password: "pa:ss", @@ -33,7 +33,7 @@ test("custom upstream proxy config creates authenticated proxy URLs", () => { }); test("none and incomplete custom upstream proxy configs do not create proxy servers", () => { - const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-gateway.config.json" }); + const config = createDefaultAppConfig(); config.proxy.upstream = { ...config.proxy.upstream, mode: "none" diff --git a/packages/electron/src/main/built-in-browser.ts b/packages/electron/src/main/built-in-browser.ts index 3388990b..0fd8f878 100644 --- a/packages/electron/src/main/built-in-browser.ts +++ b/packages/electron/src/main/built-in-browser.ts @@ -1022,10 +1022,6 @@ function normalizeGatewayBrowserAppHost(host: string): string { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function sanitizeBrowserAppId(value: string): string { return value .toLowerCase() @@ -1034,10 +1030,6 @@ function sanitizeBrowserAppId(value: string): string { .slice(0, 80); } -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function isBrowserHomeUrl(value: string): boolean { return value.trim().toLowerCase() === browserHomeUrl; } diff --git a/packages/electron/src/main/claude-design-window.ts b/packages/electron/src/main/claude-design-window.ts index acd0f523..66537ba1 100644 --- a/packages/electron/src/main/claude-design-window.ts +++ b/packages/electron/src/main/claude-design-window.ts @@ -1,6 +1,6 @@ import { gunzipSync } from "node:zlib"; import type { Event as ElectronEvent, Session, WebContents } from "electron"; -import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { loadPersistedApiKeys } from "@ccr/core/config/config-repository"; import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index bab57c1d..476add08 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { deflateSync, inflateSync } from "node:zlib"; -import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; +import { loadOnboardingFinished, markOnboardingFinished } from "@ccr/core/config/onboarding-state"; import { builtInBrowserService } from "./built-in-browser"; import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; @@ -12,7 +12,21 @@ import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway- import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config"; -import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; +import { + APP_CONFIG_DB_FILE, + APP_NAME, + CONFIGDIR, + DATADIR, + IPC_CHANNELS, + LEGACY_ACTIVE_CONFIG_FILE, + LEGACY_APP_CONFIG_DB_FILES, + LEGACY_API_KEYS_DB_FILES, + LEGACY_CONFIG_FILE, + LEGACY_WINDOWS_CONFIG_FILE, + PROXY_CA_CERT_FILE, + REQUEST_LOGS_DB_FILE, + USAGE_DB_FILE +} from "@ccr/core/config/constants"; import { deepLinkService } from "./deep-link"; import { gatewayService } from "@ccr/core/gateway/service"; import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change"; @@ -38,7 +52,6 @@ import { getUsageStats } from "@ccr/core/usage/store"; import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; import { CLAUDE_DESIGN_PLUGIN_ID, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, type AgentAnalysisFilter, type AgentAnalysisTracePayloadRequest, type ApiKeyConfig, type AppCaptureElementPngRequest, type AppCaptureElementPngResult, type AppConfig, type AppDataExportResult, type AppImageExportTargetRequest, type AppImageExportTargetResult, type AppInfo, type AppRenderHtmlPngRequest, type AppRenderHtmlPngResult, type AppSaveConfigOptions, type BotGatewayQrLoginCancelRequest, type BotGatewayQrLoginStartRequest, type BotGatewayQrLoginWaitRequest, type BotGatewayQrWindowCloseRequest, type BotGatewayQrWindowOpenRequest, type GatewayPluginAppConfig, type GatewayPluginPermission, type GatewayPluginSurface, type GatewayProviderConnectivityCheckRequest, type GatewayProviderProbeCandidatesRequest, type GatewayProviderProbeRequest, type GatewayStatus, type LocalAgentProviderImportRequest, type PluginDependency, type PluginDirectorySelection, type ProfileApplyResult, type ProfileOpenRequest, type ProfileOpenResult, type ProviderAccountResetRequest, type ProviderAccountSnapshotRequestOptions, type ProviderAccountTestRequest, type ProviderCatalogModelsRequest, type ProviderIconDetectionRequest, type ProviderManifestFetchRequest, type RequestLogListFilter, type RouteScriptTestRequest, type RouteScriptValidationRequest, type UsageStatsFilter, type UsageStatsRange } from "@ccr/core/contracts/app"; -const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const imageExportTargets = new Map(); const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); const gatewayPluginSurfaceIdSet = new Set(GATEWAY_PLUGIN_SURFACE_IDS); @@ -52,14 +65,11 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; return { - appConfigDbFile: APP_CONFIG_DB_FILE, - apiKeysDbFile: API_KEYS_DB_FILE, ...(chatgptAppPath ? { chatgptAppPath } : {}), + configDbFile: APP_CONFIG_DB_FILE, configDir: CONFIGDIR, - configFile: CONFIG_FILE, dataDir: DATADIR, desktop: true, - gatewayConfigFile: GATEWAY_CONFIG_FILE, launchAtLoginSupported: isLaunchAtLoginSupported(), name: APP_NAME, ...(opencodeAppPath ? { opencodeAppPath } : {}), @@ -84,10 +94,7 @@ ipcMain.handle(IPC_CHANNELS.appRenderHtmlPng, async (event, request: AppRenderHt }); ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig()); -ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => { - const persisted = await loadPersistedAppSetting(onboardingFinishedAtSettingKey); - return Boolean(readString(persisted) || existsSync(ONBOARDING_FINISHED_FILE)); -}); +ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, () => loadOnboardingFinished()); ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests()); ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates()); ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => { @@ -331,7 +338,8 @@ ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig return nextConfig; }); ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, async () => { - await replacePersistedAppSetting(onboardingFinishedAtSettingKey, new Date().toISOString()); + await markOnboardingFinished(); + windowsManager.setOnboardingFinished(true); windowsManager.resizeMainWindowToScreenSize(); return true; }); @@ -402,10 +410,7 @@ async function exportAppData(window: BrowserWindow | null): Promise path.resolve(item))); if (reserved.has(target)) { diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 5e93029b..0641a10c 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog, shell } from "electron"; import { setupApplicationMenu } from "./app-menu"; import { loadAppConfig } from "@ccr/core/config/config"; +import { loadOnboardingFinished } from "@ccr/core/config/onboarding-state"; import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; import { deepLinkService } from "./deep-link"; import { gatewayService } from "@ccr/core/gateway/service"; @@ -44,7 +45,9 @@ function startPrimaryInstance(): void { }); void app.whenReady().then(async () => { - applyNativeThemePreference((await loadAppConfig()).theme); + const config = await loadAppConfig(); + applyNativeThemePreference(config.theme); + windowsManager.setOnboardingFinished(await loadOnboardingFinished()); configureProxyDesktopIntegration(); let ccrLauncherPreparation: CcrCliLauncherPreparation | undefined; try { diff --git a/packages/electron/src/main/main.ts b/packages/electron/src/main/main.ts index f4002268..ac9efa25 100644 --- a/packages/electron/src/main/main.ts +++ b/packages/electron/src/main/main.ts @@ -8,11 +8,12 @@ import { copyMissingDirectoryContents, sameFilesystemPath } from "@ccr/core/stor installSocketTypeOfServiceCompat(); markDesktopAppRuntime(); -const appDataPath = app.getPath("appData"); -const homePath = app.getPath("home"); +const appDataPath = readConfiguredRuntimePath("CCR_INTERNAL_APP_DATA_DIR") ?? app.getPath("appData"); +const homePath = readConfiguredRuntimePath("CCR_INTERNAL_HOME_DIR") ?? app.getPath("home"); setRuntimeAppPaths({ appData: appDataPath, - home: homePath + home: homePath, + userData: readConfiguredRuntimePath("CCR_INTERNAL_USER_DATA_DIR") }); const userDataPath = configureRuntimeUserDataPath(app.getPath("userData")); setRuntimeAppPaths({ @@ -54,6 +55,11 @@ function reportFatalStartupError(error: unknown): void { app.exit(1); } +function readConfiguredRuntimePath(key: string): string | undefined { + const value = process.env[key]?.trim(); + return value || undefined; +} + function configureRuntimeUserDataPath(currentUserDataPath: string): string { const sharedUserDataPath = resolveRuntimeDataDir(); mkdirSync(sharedUserDataPath, { recursive: true }); diff --git a/packages/electron/src/main/tray-controller.ts b/packages/electron/src/main/tray-controller.ts index bf012da6..d35046e1 100644 --- a/packages/electron/src/main/tray-controller.ts +++ b/packages/electron/src/main/tray-controller.ts @@ -32,7 +32,6 @@ const trayMascotIconPaths: Record = { }; class TrayController { - private activeDetailProvider?: string; private detailCloseTimer?: NodeJS.Timeout; private detailOpen = false; private detailPopover?: BrowserWindow; @@ -82,7 +81,6 @@ class TrayController { hidePopover(): void { this.clearDetailCloseTimer(); this.detailOpen = false; - this.activeDetailProvider = undefined; this.hideDetailPopover(); if (this.popover && !this.popover.isDestroyed()) { this.popover.hide(); @@ -184,7 +182,6 @@ class TrayController { const popover = this.ensurePopover(); this.clearDetailCloseTimer(); this.detailOpen = false; - this.activeDetailProvider = undefined; this.hideDetailPopover(); const { menu } = resolvePopoverLayout(this.tray?.getBounds(), false); @@ -240,52 +237,6 @@ class TrayController { return this.popover; } - private ensureDetailPopover(provider?: string): BrowserWindow { - if (this.detailPopover && !this.detailPopover.isDestroyed()) { - return this.detailPopover; - } - - this.detailPopover = new BrowserWindow({ - acceptFirstMouse: true, - alwaysOnTop: true, - frame: false, - fullscreenable: false, - hasShadow: true, - height: popoverPreferredHeight - popoverDetailTopOffset, - maximizable: false, - minimizable: false, - movable: false, - roundedCorners: true, - resizable: false, - show: false, - skipTaskbar: true, - title: `${APP_NAME} Usage Detail`, - ...trayWindowMaterialOptions(), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - preload: path.join(__dirname, "preload.js"), - sandbox: true, - webSecurity: true, - zoomFactor: 1 - }, - width: popoverDetailWidth - }); - - reinforceTrayWindowMaterial(this.detailPopover); - prepareTrayWindowForSharpRendering(this.detailPopover); - this.detailPopover.setAlwaysOnTop(true, "pop-up-menu"); - this.detailPopover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - this.detailPopover.on("blur", () => this.handlePopoverBlur()); - this.detailPopover.on("closed", () => { - this.detailPopover = undefined; - }); - - this.activeDetailProvider = normalizeDetailProvider(provider); - void this.detailPopover.loadURL(createTrayPageUrl("detail", this.activeDetailProvider)); - return this.detailPopover; - } - private showContextMenu(): void { const menu = Menu.buildFromTemplate([ { @@ -316,35 +267,6 @@ class TrayController { windowsManager.showMainWindow(); } - private showDetailPopover(provider?: string): void { - if (!this.popover || this.popover.isDestroyed() || !this.popover.isVisible()) { - return; - } - - this.clearDetailCloseTimer(); - this.detailOpen = true; - - const { detail, menu } = resolvePopoverLayout(this.tray?.getBounds(), true); - if (detail.width < 320) { - this.detailOpen = false; - return; - } - - this.popover.setBounds(menu, false); - - const detailPopover = this.ensureDetailPopover(provider); - const nextProvider = normalizeDetailProvider(provider); - if (this.activeDetailProvider !== nextProvider) { - this.activeDetailProvider = nextProvider; - void detailPopover.loadURL(createTrayPageUrl("detail", nextProvider)); - } - - detailPopover.setBounds(detail, false); - this.ignorePopoverBlurUntil = Date.now() + 120; - detailPopover.showInactive(); - detailPopover.moveTop(); - } - private scheduleDetailClose(): void { this.clearDetailCloseTimer(); this.detailCloseTimer = setTimeout(() => { @@ -571,11 +493,6 @@ function createTrayPageUrl(mode: "detail" | "menu", provider?: string): string { return url.toString(); } -function normalizeDetailProvider(provider?: string): string | undefined { - const trimmed = provider?.trim(); - return trimmed ? trimmed : undefined; -} - function reinforceTrayWindowMaterial(window: BrowserWindow): void { const applyMaterial = () => { applyTrayWindowMaterial(window); diff --git a/packages/electron/src/main/windows.ts b/packages/electron/src/main/windows.ts index 9887b6fa..992f79b5 100644 --- a/packages/electron/src/main/windows.ts +++ b/packages/electron/src/main/windows.ts @@ -1,8 +1,8 @@ import { app, BrowserWindow, screen, shell } from "electron"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { APP_NAME, IPC_CHANNELS, ONBOARDING_FINISHED_FILE } from "@ccr/core/config/constants"; +import { APP_NAME, IPC_CHANNELS } from "@ccr/core/config/constants"; import { configureClaudeDesignWindowCdp, type ClaudeDesignWindowCdpOptions } from "./claude-design-window"; type WindowName = "main" | string; @@ -42,8 +42,13 @@ const pluginAppScreenshotCaptures = new WeakSet(); const pluginAppScriptRuns = new WeakSet(); class WindowsManager { + private onboardingFinished = false; private windows = new Map(); + setOnboardingFinished(finished: boolean): void { + this.onboardingFinished = finished; + } + createMainWindow(): BrowserWindow { const existing = this.getWindow("main"); if (existing) { @@ -51,7 +56,7 @@ class WindowsManager { return existing; } - const bounds = getMainWindowInitialBounds(); + const bounds = getMainWindowInitialBounds(this.onboardingFinished); const window = new BrowserWindow({ ...bounds, @@ -296,10 +301,10 @@ function fitWindowSize(preferred: number, minimum: number, available: number): n return Math.max(minimum, Math.min(preferred, available > 0 ? available : preferred)); } -function getMainWindowInitialBounds(): WindowBounds { +function getMainWindowInitialBounds(onboardingFinished: boolean): WindowBounds { const { height: availableHeight, width: availableWidth } = screen.getPrimaryDisplay().workAreaSize; - if (existsSync(ONBOARDING_FINISHED_FILE)) { + if (onboardingFinished) { return getMainWindowScreenBounds(); } diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index b8f959dc..0ba99825 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -7,7 +7,7 @@ import { createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis, copyTextToClipboard, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft, createProfileDraftFromProfile, createProviderDraft, createProviderDraftFromDeepLinkPayload, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, - createVirtualModelDraft, createVirtualModelDraftFromProfile, customProviderPresetId, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, + createVirtualModelDraft, createVirtualModelDraftFromProfile, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, fallbackGatewayStatus, fallbackInfo, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, @@ -19,8 +19,8 @@ import { isTraySupportedPlatform, LayoutGroup, mergeModelDisplayNames, mergeModelMetadata, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, modelMetadataForModels, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProxyConfig, - normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, - normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, + normalizeProfileItem, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, + normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parseProviderAccountDraft, pluginConfigPatchFromSettingsDraft, providerCredentialsFromDraft, persistLanguagePreference, PluginInstallCandidate, PluginMarketplaceEntry, PluginRoutingConfigTarget, PluginSettingsDraft, presetCapabilitiesFromDraft, @@ -206,8 +206,8 @@ function App() { const [updateDialogStatus, setUpdateDialogStatus] = useState(fallbackUpdateStatus); const [gatewayActionBusy, setGatewayActionBusy] = useState(false); const [gatewayActionTargetActive, setGatewayActionTargetActive] = useState(); - const [actionMessage, setActionMessage] = useState(""); - const [actionError, setActionError] = useState(""); + const [, setActionMessage] = useState(""); + const [, setActionError] = useState(""); const [profileActionError, setProfileActionError] = useState(""); const [profileAddOpen, setProfileAddOpen] = useState(false); const [profileAgentTab, setProfileAgentTab] = useState("claude-code"); @@ -2130,21 +2130,6 @@ function App() { setPluginSettingsError(""); } - function openConfigurePluginRouting(index: number) { - const item = draftConfig.plugins[index]; - if (!item) { - return; - } - if (isClaudeDesignPluginConfig(item)) { - setClaudeDesignRoutingDraft(createClaudeDesignRoutingDraft(item.config)); - } else if (isCursorProxyPluginConfig(item)) { - setCursorProxyRoutingDraft(createCursorProxyRoutingDraft(item.config)); - } else { - return; - } - setPluginRoutingConfigTarget({ index }); - } - function updateClaudeDesignRoutingDraft(patch: Partial) { setClaudeDesignRoutingDraft((current) => ({ ...current, ...patch })); } @@ -2602,24 +2587,6 @@ function App() { setProfileActionError(""); } - function openProfileDialog(index: number) { - const profile = draftConfig.profile.profiles[index]; - if (!profile?.enabled) { - return; - } - setProfileActionError(""); - const surfaces = profileOpenSurfaces(profile); - if (surfaces.length > 1) { - void showProfileCliCommand(profile, "choose"); - return; - } - if (surfaces[0] === "app") { - void openProfileApp(profile); - return; - } - void showProfileCliCommand(profile); - } - async function copyProfileCliCommand(index: number) { const profile = draftConfig.profile.profiles[index]; if (!profile?.enabled || !profileOpenSurfaces(profile).includes("cli") || profileActionBusy) { @@ -2721,31 +2688,6 @@ function App() { } } - async function showProfileCliCommand(profile: ProfileConfig, mode: "choose" | "cli" = "cli") { - const fallbackCommand = profileOpenCommandFallback(profile, "cli"); - setProfileOpenDialog({ busy: "cli", command: fallbackCommand, mode, profile }); - if (!(await persistConfig(draftConfig, setProfileActionError))) { - setProfileOpenDialog((current) => current?.profile.id === profile.id - ? { ...current, busy: "", error: profileActionError || t("Failed to save profile before opening.") } - : current); - return; - } - if (!window.ccr?.getProfileOpenCommand) { - setProfileOpenDialog((current) => current?.profile.id === profile.id ? { ...current, busy: "" } : current); - return; - } - try { - const result = await window.ccr.getProfileOpenCommand({ profileId: profile.id, surface: "cli" }); - setProfileOpenDialog((current) => current?.profile.id === profile.id - ? { ...current, busy: "", command: result.command, error: "" } - : current); - } catch (error) { - setProfileOpenDialog((current) => current?.profile.id === profile.id - ? { ...current, busy: "", error: formatError(error) } - : current); - } - } - async function showProfileReadyDialog(profile: ProfileConfig) { if (!profile.enabled) { return; diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index c818b658..39ed82a5 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -8,7 +8,7 @@ import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountDetailDate, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, - formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, + formatStatusBucketDate, formatSystemStatusRange, formatUsdCost, KeyboardSensor, LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricTone, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, @@ -1172,7 +1172,6 @@ function overviewMetricShowsRatio(metric: OverviewMetricKind): boolean { function UsageTrendWidget({ dimensions, - usageRange, usageStats, variant }: { @@ -3472,194 +3471,6 @@ export function AgentAnalysisView({ ); } -function AgentEndpointsCard({ endpoints }: { endpoints: AgentAnalysisSnapshot["endpoints"] }) { - const t = useAppText(); - - return ( - - - {t("Endpoint Health")} - {endpoints.length} - - - {endpoints.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - - - {endpoints.map((endpoint) => ( - - - - - - - - - - - ))} - -
{t("Path")}{t("Agent")}{t("Requests")}{t("Success rate")}{t("P95")}{t("Max concurrent")}{t("Cache")}{t("Status codes")}
- {endpoint.method} {endpoint.path} - {t(agentKindLabel(endpoint.agent))}{formatCompactNumber(endpoint.requestCount)}{formatPercent(endpoint.successRate)}{formatDuration(endpoint.p95DurationMs)}{formatCompactNumber(endpoint.maxConcurrentRequests)}{formatPercent(endpoint.cacheRatio)}{formatStatusCodeCounts(endpoint.statusCodes)}
-
- )} -
-
- ); -} - -function AgentClientsCard({ clients }: { clients: AgentAnalysisSnapshot["clients"] }) { - const t = useAppText(); - - return ( - - - {t("Client Signals")} - {clients.length} - - - {clients.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - - {clients.map((client) => ( - - - - - - - - - - ))} - -
{t("Client")}{t("Agent")}{t("Sessions")}{t("Requests")}{t("Success rate")}{t("P95")}{t("UA")}
{client.label}{t(agentKindLabel(client.agent))}{formatCompactNumber(client.sessionCount)}{formatCompactNumber(client.requestCount)}{formatPercent(client.successRate)}{formatDuration(client.p95DurationMs)}{compactUserAgent(client.userAgent)}
-
- )} -
-
- ); -} - -function AgentRoutesCard({ routes }: { routes: AgentAnalysisSnapshot["routes"] }) { - const t = useAppText(); - - return ( - - - {t("Route Observability")} - {routes.length} - - - {routes.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {routes.map((route) => ( - - - - - - - - - ))} - -
{t("Route")}{t("Agent")}{t("Model")}{t("Requests")}{t("Success rate")}{t("P95")}
{formatRouteReason(route.routeReason)}{t(agentKindLabel(route.agent))}{route.provider}/{route.model}{formatCompactNumber(route.requestCount)}{formatPercent(route.successRate)}{formatDuration(route.p95DurationMs)}
-
- )} -
-
- ); -} - -function AgentErrorsCard({ errors }: { errors: AgentAnalysisSnapshot["errors"] }) { - const t = useAppText(); - - return ( - - - {t("Recent Errors")} - {errors.length} - - - {errors.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {errors.map((error) => ( - - - - - - - - - ))} - -
{t("Time")}{t("Status")}{t("Path")}{t("Agent")}{t("Route")}{t("Duration")}
{formatLogDateTime(error.createdAt)}{error.statusCode || "-"}{error.method} {error.path}{t(agentKindLabel(error.agent))}{formatRouteReason(error.routeReason)}{formatDuration(error.durationMs)}
-
- )} -
-
- ); -} - function AgentSessionDetailCard({ clearSession, detail, @@ -4205,32 +4016,6 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string { return "bg-blue-500"; } -function SessionMetricCell({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function SessionInlineList({ title, value }: { title: string; value: string }) { - return ( -
-
{title}
-
{value || "-"}
-
- ); -} - -function formatToolRows(tools: AgentAnalysisSnapshot["tools"]): string { - return tools.slice(0, 5).map((tool) => `${tool.name} (${formatCompactNumber(tool.count)})`).join(", "); -} - -function formatRouteRows(routes: AgentAnalysisSnapshot["routes"]): string { - return routes.slice(0, 5).map((route) => `${formatRouteReason(route.routeReason)}: ${formatCompactNumber(route.requestCount)}`).join(", "); -} - function formatRouteReason(value: string | undefined): string { const trimmed = value?.trim(); if (!trimmed) { @@ -4311,149 +4096,6 @@ function AgentSessionsCard({ ); } -function AgentToolsCard({ tools }: { tools: AgentAnalysisSnapshot["tools"] }) { - const t = useAppText(); - - return ( - - - {t("Tool Usage")} - {tools.length} - - - {tools.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - {tools.map((tool) => ( - - - - - - - - ))} - -
{t("Tool")}{t("Tool calls")}{t("Requests")}{t("Sessions")}{t("Agent")}
{tool.name}{formatCompactNumber(tool.count)}{formatCompactNumber(tool.requestCount)}{formatCompactNumber(tool.sessions)}{tool.agents.map(agentKindLabel).map(t).join(", ")}
-
- )} -
-
- ); -} - -function AgentSubagentsCard({ subagents }: { subagents: AgentAnalysisSnapshot["subagents"] }) { - const t = useAppText(); - - return ( - - - {t("Subagent Routing")} - {subagents.length} - - - {subagents.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - {subagents.map((subagent) => ( - - - - - - - - ))} - -
{t("Session")}{t("Model")}{t("Requests")}{t("Tokens")}{t("Cache")}
{compactId(subagent.sessionId)}{subagent.provider}/{subagent.model}{formatCompactNumber(subagent.count)}{formatCompactNumber(subagent.totalTokens)}{formatCompactNumber(subagent.cacheReadTokens + subagent.cacheWriteTokens)}
-
- )} -
-
- ); -} - -function AgentRecentRequestsCard({ requests }: { requests: AgentAnalysisSnapshot["recentRequests"] }) { - const t = useAppText(); - - return ( - - - {t("Recent Requests")} - {requests.length} - - - {requests.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - - - - - - - {requests.map((request) => ( - - - - - - - - - - - - - - - ))} - -
{t("Time")}{t("Agent")}{t("Client")}{t("Status")}{t("Session")}{t("Route")}{t("Model")}{t("Tools")}{t("Subagents")}{t("Cache")}{t("Concurrency")}{t("Duration")}
{formatLogDateTime(request.createdAt)}{t(agentKindLabel(request.agent))}{request.client}{request.statusCode || "-"}{compactId(request.sessionId)}{formatRouteReason(request.routeReason)}{request.provider}/{request.model}{formatCompactNumber(request.toolCallCount)}{request.subagentModel ? request.subagentModel : "-"}{formatCompactNumber(request.cacheReadTokens + request.cacheWriteTokens)}{formatCompactNumber(request.concurrentRequests)}{formatDuration(request.durationMs)}
-
- )} -
-
- ); -} - function AnalysisEmptyState({ label }: { label: string }) { return (
diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 97e87726..7e4b9f0b 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -112,7 +112,6 @@ export function MainLayout({ updateActionBusy, updateStatus, viewProps, - requestLogsEnabled, visibleNavigation }: { activeView: ViewId; diff --git a/packages/ui/src/pages/home/components/onboarding.tsx b/packages/ui/src/pages/home/components/onboarding.tsx index a3bdac62..807ae0c2 100644 --- a/packages/ui/src/pages/home/components/onboarding.tsx +++ b/packages/ui/src/pages/home/components/onboarding.tsx @@ -37,27 +37,6 @@ const onboardingStepDetails: Record = { - cyan: { - accent: "#8CF7FF", - glow: "rgba(34, 211, 238, 0.22)", - main: "#22D3EE", - shadow: "rgba(8, 145, 178, 0.22)" - }, - orange: { - accent: "#FFD166", - glow: "rgba(249, 115, 22, 0.2)", - main: "#F97316", - shadow: "rgba(194, 65, 12, 0.22)" - }, - violet: { - accent: "#C084FC", - glow: "rgba(139, 92, 246, 0.2)", - main: "#8B5CF6", - shadow: "rgba(109, 40, 217, 0.22)" - } -}; - export function OnboardingView({ activeStep, agentOptions, diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 253d8663..72e1725f 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -514,56 +514,6 @@ function ProfileCliCommandBlock({ ); } -function ProfileAgentTabs({ - activeAgent, - agentOptions, - profiles, - setActiveAgent -}: { - activeAgent: ProfileConfig["agent"]; - agentOptions: ProfileAgentOption[]; - profiles: ProfileConfig[]; - setActiveAgent: (agent: ProfileConfig["agent"]) => void; -}) { - const t = useAppText(); - - return ( -
- {agentOptions.map((option) => { - const agent = option.value; - const selected = activeAgent === agent; - const count = profiles.filter((profile) => profile.agent === agent).length; - - return ( - - ); - })} -
- ); -} - function AgentSelectControl({ agentOptions, onChange, diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 585b433c..725878dd 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -1275,19 +1275,6 @@ function openExternalUrl(url: string) { window.open(url, "_blank", "noopener,noreferrer"); } -function providerBaseOrigin(value: string): string | undefined { - const url = normalizedHttpUrl(value); - if (!url) { - return undefined; - } - - try { - return new URL(url).origin; - } catch { - return undefined; - } -} - function normalizedHttpUrl(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) { @@ -1724,8 +1711,6 @@ function ProviderFormStepHeader({ index: number; title: string; }) { - const t = useAppText(); - return (
diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 53a527c5..a6149c92 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -1,7 +1,7 @@ import { Activity, AppConfig, AppCopy, AppInfo, AppLanguagePreference, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform, botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions, - botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button, + botGatewaySavedConfigFromDraft, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, endpointFromHostPort, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, Globe, @@ -1157,8 +1157,7 @@ function DataSettingsSection({
- - +
diff --git a/packages/ui/src/pages/home/shared/api-keys.ts b/packages/ui/src/pages/home/shared/api-keys.ts index 58bf9729..e27e51c4 100644 --- a/packages/ui/src/pages/home/shared/api-keys.ts +++ b/packages/ui/src/pages/home/shared/api-keys.ts @@ -1,376 +1,8 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + ApiKeyLimitConfig } from "@ccr/core/contracts/app"; -import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { isPlainRecord, stringValue } from "./common"; import type { AddApiKeyDraft, ApiKeyLimitDraftRow, ApiKeyLimitMetric, ApiKeyListItem, LimitWindowPreset } from "./types"; diff --git a/packages/ui/src/pages/home/shared/common.ts b/packages/ui/src/pages/home/shared/common.ts index f510fbab..8aa2a27d 100644 --- a/packages/ui/src/pages/home/shared/common.ts +++ b/packages/ui/src/pages/home/shared/common.ts @@ -1,376 +1,30 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType, OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + TrayWindowModuleId } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, type AppCopy } from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { positiveInteger } from "./api-keys"; import type { MetricTone } from "./controls"; diff --git a/packages/ui/src/pages/home/shared/config.ts b/packages/ui/src/pages/home/shared/config.ts index 8e0092b0..15aa790b 100644 --- a/packages/ui/src/pages/home/shared/config.ts +++ b/packages/ui/src/pages/home/shared/config.ts @@ -1,376 +1,9 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, - AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + AppConfig } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus + fallbackConfig } from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { normalizeApiKeys } from "./api-keys"; import { normalizeOverviewWidgets, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayComponentVariants, normalizeTrayIconPreference, normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules } from "./common"; diff --git a/packages/ui/src/pages/home/shared/controls.tsx b/packages/ui/src/pages/home/shared/controls.tsx index abd3a832..d216cc3f 100644 --- a/packages/ui/src/pages/home/shared/controls.tsx +++ b/packages/ui/src/pages/home/shared/controls.tsx @@ -1,96 +1,14 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { MorphIcon } from "@musistudio/lucide-morph-react"; +import { AnimatePresence } from "motion/react"; import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pencil, Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon + X } from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PopoverContent } from "@/components/ui/popover"; @@ -99,278 +17,29 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { playPauseMorph } from "@/lib/morph-icon"; import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, UsageSeriesPoint, - UsageStatsFilter, UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + UsageTotals } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, translateOptions, - translateText, - useAppText, - type AppCopy + useAppText } from "./i18n"; import { - AnimatedDisclosure, AnimatedIconSwap, - AnimatedFieldSlot, - AnimatedListItem, - AnimatedPopover, - disclosureSpringTransition, - listSpringTransition, - pageSpringTransition, - ViewMotionShell + AnimatedPopover } from "./motion"; import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows + formatDuration } from "./network"; import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue + formatPercent } from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { metricToneBar, normalizeProviderModelSelector } from "./common"; import { profileAgentLabel, profileAgentLogoUrl } from "./profiles"; diff --git a/packages/ui/src/pages/home/shared/extensions.ts b/packages/ui/src/pages/home/shared/extensions.ts index 110a745c..e095cb56 100644 --- a/packages/ui/src/pages/home/shared/extensions.ts +++ b/packages/ui/src/pages/home/shared/extensions.ts @@ -1,381 +1,17 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, GATEWAY_PLUGIN_PERMISSION_IDS, - GATEWAY_PLUGIN_SURFACE_IDS, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS + GATEWAY_PLUGIN_SURFACE_IDS } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, GatewayProviderConfig, - GatewayProviderCapability, GatewayPluginAppConfig, GatewayPluginConfig, GatewayPluginPermission, GatewayPluginSurfacesConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + PluginMarketplaceEntry } from "@ccr/core/contracts/app"; -import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { isPlainRecord, stringValue } from "./common"; import { isClaudeDesignPluginConfig, isCursorProxyPluginConfig, readClaudeDesignRoutingConfig } from "./routing"; diff --git a/packages/ui/src/pages/home/shared/external.tsx b/packages/ui/src/pages/home/shared/external.tsx index ab3d94f3..8eac742e 100644 --- a/packages/ui/src/pages/home/shared/external.tsx +++ b/packages/ui/src/pages/home/shared/external.tsx @@ -128,13 +128,11 @@ import { BUILTIN_FUSION_TOOL_SERVER_NAME, BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, @@ -169,10 +167,6 @@ import type { GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig, @@ -220,10 +214,6 @@ import type { RouterFallbackConfig, RouterFallbackMode, RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, RouterRuleType, TrayBalanceProgressConfig, TrayComponentVariants, @@ -347,9 +337,6 @@ import { getNextOnboardingStep, isOnboardingProfileReady, isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, limitWindowOptions, mcpServerStartupTimeoutMs, mcpServerTransportOptions, diff --git a/packages/ui/src/pages/home/shared/fallbacks.ts b/packages/ui/src/pages/home/shared/fallbacks.ts index d9b4ebc2..da7445b6 100644 --- a/packages/ui/src/pages/home/shared/fallbacks.ts +++ b/packages/ui/src/pages/home/shared/fallbacks.ts @@ -10,13 +10,10 @@ import type { import { createDefaultAppConfig } from "@ccr/core/config/default-config"; export const fallbackInfo: AppInfo = { - appConfigDbFile: "Browser preview", - apiKeysDbFile: "Browser preview", + configDbFile: "Browser preview", configDir: "Browser preview", - configFile: "Browser preview", dataDir: "Browser preview", desktop: false, - gatewayConfigFile: "Browser preview", launchAtLoginSupported: /^Mac|^Win/i.test(navigator.platform), name: "Claude Code Router", platform: navigator.platform, @@ -34,14 +31,11 @@ export const fallbackUpdateStatus: AppUpdateStatus = { supported: false }; -export const fallbackConfig: AppConfig = createDefaultAppConfig({ - generatedConfigFile: "Browser preview" -}); +export const fallbackConfig: AppConfig = createDefaultAppConfig({}); export const fallbackGatewayStatus: GatewayStatus = { coreEndpoint: "http://127.0.0.1:3457", endpoint: "http://127.0.0.1:3456", - generatedConfigFile: "Browser preview", networkEndpoints: [], state: "stopped" }; diff --git a/packages/ui/src/pages/home/shared/logs.ts b/packages/ui/src/pages/home/shared/logs.ts index 13fae88e..630f957f 100644 --- a/packages/ui/src/pages/home/shared/logs.ts +++ b/packages/ui/src/pages/home/shared/logs.ts @@ -1,376 +1,10 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, - AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + RequestLogEntry } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue + formatCompactNumber } from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { isPlainRecord, stringValue } from "./common"; diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 8d727612..313a41d4 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -1,105 +1,3 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; import grokLogoUrl from "@/assets/agent-logos/grok.ico"; @@ -107,275 +5,28 @@ import kiloLogoUrl from "@/assets/agent-logos/kilo.svg"; import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; import piLogoUrl from "@/assets/agent-logos/pi.svg"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS + normalizeProfileScopeValue } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, - BotHandoffScanTarget, GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, ProfileConfig, ProfileOpenSurface, CodexProfileConfigFormat, ProfileScope, ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + VirtualModelProfileConfig } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus + fallbackConfig } from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { virtualModelProfileModelNames } from "./providers"; diff --git a/packages/ui/src/pages/home/shared/provider-accounts.ts b/packages/ui/src/pages/home/shared/provider-accounts.ts index 3e726ae9..ff7b5335 100644 --- a/packages/ui/src/pages/home/shared/provider-accounts.ts +++ b/packages/ui/src/pages/home/shared/provider-accounts.ts @@ -1,376 +1,10 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, - AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + ProviderAccountSnapshot } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue + formatCompactNumber } from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - export function compareProviderAccountSnapshots(a: ProviderAccountSnapshot, b: ProviderAccountSnapshot): number { return ( diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index c5d96aa6..789c0623 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -1,384 +1,53 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; import codexLogoUrl from "@/assets/agent-logos/codex.png"; import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - ROUTER_SCRIPT_MAX_TIMEOUT_MS, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS + ROUTER_SCRIPT_MAX_TIMEOUT_MS } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, GatewayProviderConfig, GatewayProviderCapability, GatewayProviderCapabilityProtocol, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, GatewayProviderProbeResult, GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, LocalAgentProviderKind, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, ProviderAccountConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, ProviderCredentialConfig, ProviderDeepLinkPayload, - ProviderDeepLinkRequest, ProviderModelMetadata, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, RouterFallbackConfig, - RouterFallbackMode, RouterRule, RouterRuleCondition, - RouterRuleOperator, RouterRuleRewrite, - RouterRuleRewriteOperation, RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + VirtualModelProfileConfig } from "@ccr/core/contracts/app"; import { customProviderPresetId, defaultProviderAccountConfig, standardProviderAccountConfig, type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint + type ProviderPreset } from "@ccr/core/providers/presets/types"; import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList + primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset } from "@ccr/core/providers/presets/utils"; import { newApiUserSelfConnectorConfig } from "@ccr/core/providers/new-api"; import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, providerPresetIconUrls, providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions + routerConditionSourceOptions } from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - +import type { RouterConditionSource } from "./options"; import { normalizeApiKeyLimits, positiveInteger } from "./api-keys"; import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; @@ -530,7 +199,7 @@ export function formatRouterRuleCondition(rule: RouterRule): string { return "condition unset"; } -export function routerRuleConditionFromRule(rule: RouterRule, config?: AppConfig): RouterRuleCondition | undefined { +export function routerRuleConditionFromRule(rule: RouterRule, _config?: AppConfig): RouterRuleCondition | undefined { if (rule.condition) { return rule.condition; } @@ -1929,7 +1598,7 @@ export function selectedProviderProtocolsFromCapabilities( export function selectedProviderProtocolsForProbe( selectedProtocols: GatewayProviderProtocol[], probe: GatewayProviderProbeResult, - fallback: GatewayProviderProtocol, + _fallback: GatewayProviderProtocol, presetId?: string ): GatewayProviderProtocol[] { const selectable = providerSelectableProtocolsFromProbe(probe); diff --git a/packages/ui/src/pages/home/shared/routing.ts b/packages/ui/src/pages/home/shared/routing.ts index d9517d25..0d556f30 100644 --- a/packages/ui/src/pages/home/shared/routing.ts +++ b/packages/ui/src/pages/home/shared/routing.ts @@ -1,213 +1,12 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, - ROUTER_SCRIPT_MAX_TIMEOUT_MS, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS + ROUTER_SCRIPT_MAX_TIMEOUT_MS } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, RouteScriptSampleRequest, RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig, @@ -219,165 +18,20 @@ import type { RouterRuleOperator, RouterRuleRewrite, RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + RouterRuleType } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus + fallbackConfig } from "./fallbacks"; import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, removedLegacyRouterRuleIds, - routerConditionSourceOptions, routerFallbackModeOptions, routerRewriteOperationOptions, routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions + routerRuleTypeOptions } from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { positiveInteger } from "./api-keys"; import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; diff --git a/packages/ui/src/pages/home/shared/services.tsx b/packages/ui/src/pages/home/shared/services.tsx index 21c41ed0..766c0cca 100644 --- a/packages/ui/src/pages/home/shared/services.tsx +++ b/packages/ui/src/pages/home/shared/services.tsx @@ -1,376 +1,14 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, - GatewayMcpServerConfig, - GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, ProxyCertificateInstallResult, ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, - VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + ProxyStatus } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy + useAppText } from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - export function endpointFromHostPort(host: string, port: number): string { const trimmedHost = host.trim() || "127.0.0.1"; diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index 524c14ff..87c90cde 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -1,380 +1,50 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { closestCenter, - DndContext, - DragOverlay, getFirstCollision, - KeyboardSensor, - MeasuringStrategy, pointerWithin, - PointerSensor, rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent + type CollisionDetection } from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, type LucideIcon } from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, - BUILTIN_FUSION_VISION_TOOL_NAME, - BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS -} from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, - AppConfig, - AppInfo, - AppUpdateStatus, ApiKeyConfig, ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, GatewayProviderCapability, - GatewayPluginAppConfig, GatewayPluginPermission, GatewayPluginSurfacesConfig, GatewayProviderConnectivityCheckModelResult, GatewayProviderConnectivityCheckReport, GatewayProviderProbeCandidate, GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, GatewayProviderProtocol, - GatewayMcpServerConfig, GatewayMcpServerTransport, GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, PluginDependency, - PluginDirectorySelection, PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, ProviderModelMetadata, ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, ProfileScope, ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, RouterBuiltInAgentRuleId, - RouterConfig, RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, RouterRuleOperator, - RouterRuleRewrite, RouterRuleRewriteOperation, RouterRuleType, - TrayBalanceProgressConfig, TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, VirtualModelBaseModelMode, VirtualModelExecutionMode, - VirtualModelFusionCustomToolConfig, - VirtualModelFusionVisionConfig, - VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, VirtualModelToolVisibility } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue + createEmptyUsageStats } from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, - customFusionToolName, - defaultFusionWebSearchProvider, - fusionToolOptions, - fusionWebSearchEnvKeysByProvider, - fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, - legacyUnimcpPackageName, - legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions -} from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - +import type { RouterConditionSource } from "./options"; export type ViewId = "onboarding" | "overview" | "observability" | "api-keys" | "server" | "profile" | "networking" | "logs" | "providers" | "models" | "routing" | "virtual-models" | "extensions"; export type NavigationId = ViewId; diff --git a/packages/ui/src/pages/home/shared/virtual-models.ts b/packages/ui/src/pages/home/shared/virtual-models.ts index 7e9ceec6..27d67a6c 100644 --- a/packages/ui/src/pages/home/shared/virtual-models.ts +++ b/packages/ui/src/pages/home/shared/virtual-models.ts @@ -1,126 +1,4 @@ -import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { - closestCenter, - DndContext, - DragOverlay, - getFirstCollision, - KeyboardSensor, - MeasuringStrategy, - pointerWithin, - PointerSensor, - rectIntersection, - useSensor, - useSensors, - type CollisionDetection, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent -} from "@dnd-kit/core"; -import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { - Activity, - ArrowDown, - ArrowUp, - Box, - Boxes, - Braces, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleAlert, - Copy, - Database, - ExternalLink, - FolderOpen, - Gauge, - Globe, - Info, - KeyRound, - Layers3, - LoaderCircle, - MoveRight, - Network, - Palette, - PanelLeftClose, - PanelLeftOpen, - Pause, - Pencil, - Play, - Plus, - Power, - QrCode, - RefreshCw, - Route, - Search, - Server, - Settings, - ShieldCheck, - Terminal, - Trash2, - UserRound, - X, - type LucideIcon -} from "lucide-react"; -import { - Area, - Bar, - BarChart, - CartesianGrid, - Cell, - ComposedChart, - LabelList, - Line, - Pie, - PieChart, - Tooltip, - XAxis, - YAxis -} from "recharts"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PopoverContent } from "@/components/ui/popover"; -import { Select } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import appLogoUrl from "@/assets/logo.png"; -import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png"; -import codexLogoUrl from "@/assets/agent-logos/codex.png"; -import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg"; -import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; -import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; -import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; -import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; -import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; -import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; -import openaiProviderIconUrl from "@/assets/provider-icons/openai.png"; -import openrouterProviderIconUrl from "@/assets/provider-icons/openrouter.ico"; -import siliconflowProviderIconUrl from "@/assets/provider-icons/siliconflow.png"; -import zaiGlobalCodingProviderIconUrl from "@/assets/provider-icons/zai-global-coding.svg"; -import zaiGlobalGeneralProviderIconUrl from "@/assets/provider-icons/zai-global-general.svg"; -import zhipuCnCodingProviderIconUrl from "@/assets/provider-icons/zhipu-cn-coding.png"; -import zhipuCnGeneralProviderIconUrl from "@/assets/provider-icons/zhipu-cn-general.png"; -import trayCyanIconUrl from "@/assets/tray-cyan.png"; -import trayOrangeIconUrl from "@/assets/tray-orange.png"; -import trayVioletIconUrl from "@/assets/tray-violet.png"; -import { - BUILTIN_FUSION_TOOL_SERVER_NAME, BUILTIN_FUSION_GROK_MEDIA_TOOL_NAME, BUILTIN_FUSION_IMAGE_GENERATION_TOOL_NAME, BUILTIN_FUSION_VISION_TOOL_NAME, @@ -137,256 +15,29 @@ import { MEDIA_IMAGE_GENERATE_TOOL_PREFIX, MEDIA_JOB_CANCEL_TOOL_PREFIX, MEDIA_JOB_GET_TOOL_PREFIX, - MEDIA_VIDEO_START_TOOL_PREFIX, - CLAUDE_CODE_DEFAULT_ENV, - DEFAULT_OVERVIEW_WIDGETS, - DEFAULT_TRAY_COMPONENT_VARIANTS, - DEFAULT_TRAY_WIDGETS, - DEFAULT_TRAY_WINDOW_MODULES, - enforceSingleEnabledGlobalProfilePerAgent, - normalizeProfileScopeValue, - OVERVIEW_WIDGET_SIZE_VALUES, - TRAY_SINGLETON_WIDGET_TYPES, - TRAY_TOP_WIDGET_TYPES, - TRAY_WINDOW_MODULE_IDS + MEDIA_VIDEO_START_TOOL_PREFIX } from "@ccr/core/contracts/app"; import type { - AgentAnalysisFilter, - AgentAnalysisSessionSelection, - AgentAnalysisSnapshot, - AgentKind, AppConfig, - AppInfo, - AppUpdateStatus, - ApiKeyConfig, - ApiKeyLimitConfig, - BotGatewayQrLoginCancelRequest, - BotGatewayQrLoginCancelResult, - BotGatewayQrLoginStartRequest, - BotGatewayQrLoginStartResult, - BotGatewayQrLoginWaitRequest, - BotGatewayQrLoginWaitResult, - BotGatewayQrWindowOpenResult, - BotGatewayRuntimeConfig, - BotGatewaySavedConfig, - BotHandoffScanTarget, - GatewayProviderConfig, - GatewayProviderCapability, - GatewayPluginAppConfig, - GatewayProviderConnectivityCheckModelResult, - GatewayProviderConnectivityCheckReport, - GatewayProviderProbeCandidate, - GatewayProviderProbeCandidateResult, - GatewayProviderProbeResult, - GatewayProviderProtocol, GatewayMcpServerConfig, GatewayMcpServerTransport, - GatewayMcpStdioMessageMode, - GatewayMcpToolInfo, - GatewayStatus, - OverviewMetricKind, - OverviewWidgetConfig, - OverviewWidgetSize, - OverviewWidgetType, - OverviewWidgetVariant, - PluginDependency, - PluginDirectorySelection, - PluginMarketplaceEntry, - ProviderAccountConfig, - ProviderAccountConnectorConfig, - ProviderAccountHttpJsonConnectorConfig, - ProviderAccountMeter, - ProviderAccountStandardConnectorConfig, - ProviderAccountSnapshot, - ProviderAccountTestPath, - ProviderAccountTestResult, - ProviderCredentialConfig, - ProviderDeepLinkPayload, - ProviderDeepLinkRequest, - ProfileConfig, - ProfileOpenSurface, - CodexProfileConfigFormat, - ProfileScope, - ProfileSurface, - ProxyCertificateInstallResult, - ProxyCertificateStatus, - ProxyNetworkBody, - ProxyNetworkExchange, - ProxyNetworkSnapshot, - ProxyStatus, - RequestLogBody, - RequestLogEntry, - RequestLogListFilter, - RequestLogPage, - RequestLogStatusFilter, - RouterConfig, - RouterFallbackConfig, - RouterFallbackMode, - RouterRule, - RouterRuleCondition, - RouterRuleOperator, - RouterRuleRewrite, - RouterRuleRewriteOperation, - RouterRuleType, - TrayBalanceProgressConfig, - TrayComponentVariants, - TrayWidgetConfig, - TrayWidgetType, - TrayWidgetVariant, - TrayWindowModuleId, - UsageComparisonRow, - UsageSeriesPoint, - UsageStatsFilter, - UsageStatsRange, - UsageStatsSnapshot, - UsageTotals, - VirtualModelBaseModelMode, - VirtualModelExecutionMode, VirtualModelFusionCustomToolConfig, VirtualModelFusionMediaConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider, - VirtualModelProfileConfig, - VirtualModelToolVisibility + VirtualModelProfileConfig } from "@ccr/core/contracts/app"; import { - customProviderPresetId, - defaultProviderAccountConfig, - standardProviderAccountConfig, - type ProviderIdentitySafetyIssue, - type ProviderPreset, - type ProviderPresetEndpoint -} from "@ccr/core/providers/presets/types"; -import { - findProviderPresetByBaseUrlInList, - findProviderPresetInList, - primaryProviderPresetEndpoint as primaryProviderPresetEndpointFromPreset, - providerApiKeySafetyIssueInList, - providerEndpointCanReceiveProviderApiKeyInList, - providerIdentitySafetyIssueInList -} from "@ccr/core/providers/presets/utils"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; -import { - fallbackConfig, - fallbackGatewayStatus, - fallbackInfo, - fallbackProxyCertificateStatus, - fallbackProxyNetworkSnapshot, - fallbackProxyStatus, - fallbackUpdateStatus -} from "./fallbacks"; -import { - AppI18nContext, - appCopy, - languagePreferenceStorageKey, - translateOptions, - translateText, - useAppText, - type AppCopy -} from "./i18n"; -import { - AnimatedDisclosure, - AnimatedFieldSlot, - AnimatedListItem, - disclosureSpringTransition, - listSpringTransition, - motionEase, - pageSpringTransition, - reducedMotionTransition, - ViewMotionShell -} from "./motion"; -import { - clientInitial, - formatBytes, - formatDuration, - formatHeaderName, - formatNetworkDateTime, - formatNetworkHeaders, - formatNetworkRequestRaw, - formatNetworkResponseRaw, - formatNetworkTime, - networkCodeLabel, - networkExchangeMatchesQuery, - networkHeaderRows, - networkLifecycleLabel, - networkQueryRows, - networkRowId, - networkStatusLabel, - networkStatusVariant, - networkSummaryRows -} from "./network"; -import { - agentKindLabel, - compactId, - compactUserAgent, - createEmptyAgentAnalysis, - createEmptyAgentConcurrencySeries, - createEmptyRequestLogPage, - createEmptyUsageSeries, - createEmptyUsageStats, - emptyUsageTotals, - formatAxisNumber, - formatCompactNumber, - formatPercent, - formatStatusCodeCounts, - formatToolCounts, - formatUsdCost, - logSelectOptions, - normalizeAgentFilterValue -} from "./usage"; -import { - agentAnalysisRangeOptions, - agentFilterOptions, - apiKeyExpirationOptions, - apiKeyLimitMetricOptions, - claudeDesignRouteRuleTypeOptions, customFusionToolName, defaultFusionWebSearchProvider, fusionToolOptions, fusionWebSearchEnvKeysByProvider, fusionWebSearchProviderOptions, - getDefaultOnboardingStep, - getNextOnboardingStep, - isOnboardingProfileReady, - isOnboardingProviderReady, - legacyRouterRuleTypes, legacyUnimcpPackageName, legacyUnimcpServerName, - limitWindowOptions, - mcpServerStartupTimeoutMs, - mcpServerTransportOptions, - mcpStdioMessageModeOptions, - navigation, - onboardingStepOrder, - overviewMetricOptions, - overviewWidgetSizeOptions, - profileAgentOptions, - profileScopeOptions, - profileSurfaceOptions, - providerAccountModeOptions, - providerPresetIconUrls, - providerProtocolOptions, - providerUsageMethodOptions, - requestLogPageSizeOptions, - requestLogStatusOptions, - removedLegacyRouterRuleIds, - routerConditionSourceOptions, - routerFallbackModeOptions, - routerRewriteOperationOptions, - routerRuleOperatorOptions, - routerRuleTypeOptions, - trayMascotIconUrls, - usageRangeOptions, - virtualModelBaseModeOptions, - virtualModelClientToolsPolicyOptions, - virtualModelExecutionModeOptions, - virtualModelMatchModeOptions, - virtualModelToolVisibilityOptions + mcpServerStartupTimeoutMs } from "./options"; -import type { AgentFilterValue, RouterConditionSource } from "./options"; -import type { MotionSafeDivAttributes } from "./motion"; - import { isPlainRecord, normalizeProviderModelSelector, stringValue, uniqueStrings } from "./common"; import { sanitizeConfigId } from "./extensions"; diff --git a/scripts/generate-models-json.mjs b/scripts/generate-models-json.mjs index f4fb3484..f2875e2c 100644 --- a/scripts/generate-models-json.mjs +++ b/scripts/generate-models-json.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -29,6 +29,9 @@ const sources = { const sourceOrder = ["models.dev", "litellm", "openrouter"]; const support1MContextThreshold = 1_000_000; const schemaVersion = 2; +const fetchAttempts = 3; +const fetchRetryDelayMs = 500; +const fetchTimeoutMs = 15_000; const firstPartyProviderAliases = new Map(Object.entries({ ai21: "ai21", @@ -81,11 +84,33 @@ const providerHints = [ ]; async function main() { - const [modelsDevPayload, liteLlmPayload, openRouterPayload] = await Promise.all([ - fetchJson(sources.modelsDev.url), - fetchJson(sources.litellm.url), - fetchJson(sources.openrouter.url) - ]); + const sourceRequests = [ + sources.modelsDev, + sources.litellm, + sources.openrouter + ]; + const sourceResults = await Promise.allSettled( + sourceRequests.map((source) => fetchJson(source.url)) + ); + const failures = sourceResults.flatMap((result, index) => + result.status === "rejected" + ? [`${sourceRequests[index].id}: ${formatError(result.reason)}`] + : [] + ); + if (failures.length > 0) { + const existingCatalog = await readExistingCatalog(); + if (existingCatalog) { + console.warn(`[models] Refresh failed (${failures.join("; ")}).`); + console.warn( + `[models] Keeping the checked-in catalog with ${existingCatalog.models.length} model records.` + ); + return; + } + throw new Error(`Failed to refresh the model catalog: ${failures.join("; ")}`); + } + const [modelsDevPayload, liteLlmPayload, openRouterPayload] = sourceResults.map( + (result) => result.value + ); const entries = new Map(); ingestModelsDev(entries, modelsDevPayload); @@ -117,11 +142,49 @@ async function main() { } async function fetchJson(url) { - const response = await fetch(url, { headers: { accept: "application/json" } }); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); + let lastError; + for (let attempt = 1; attempt <= fetchAttempts; attempt += 1) { + try { + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(fetchTimeoutMs) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.json(); + } catch (error) { + lastError = error; + if (attempt < fetchAttempts) { + await delay(fetchRetryDelayMs * attempt); + } + } } - return response.json(); + throw new Error( + `Failed to fetch ${url} after ${fetchAttempts} attempts: ${formatError(lastError)}` + ); +} + +async function readExistingCatalog() { + try { + const payload = JSON.parse(await readFile(outputPath, "utf8")); + return isRecord(payload) && + payload.schemaVersion === schemaVersion && + Array.isArray(payload.models) && + payload.models.length > 0 + ? payload + : undefined; + } catch { + return undefined; + } +} + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); } function ingestModelsDev(entries, payload) { diff --git a/tests/architecture/package-boundaries.test.mjs b/tests/architecture/package-boundaries.test.mjs index 589a03b8..cac393c9 100644 --- a/tests/architecture/package-boundaries.test.mjs +++ b/tests/architecture/package-boundaries.test.mjs @@ -6,9 +6,12 @@ import test from "node:test"; const projectRoot = process.cwd(); const packageNames = ["cli", "core", "electron", "ui"]; -test("legacy Electron process test suites have been removed", () => { - assert.equal(existsSync(path.join(projectRoot, "tests", "main")), false); - assert.equal(existsSync(path.join(projectRoot, "tests", "renderer")), false); +test("legacy Electron process test suites contain no source files", () => { + for (const directoryName of ["main", "renderer"]) { + const directory = path.join(projectRoot, "tests", directoryName); + const files = existsSync(directory) ? sourceFiles(directory) : []; + assert.deepEqual(files, [], `${directoryName} must not contain legacy test sources`); + } }); test("every workspace package owns a test command and test directory", () => { diff --git a/tests/e2e/cli-web.spec.ts b/tests/e2e/cli-web.spec.ts index 095a4435..c02db67c 100644 --- a/tests/e2e/cli-web.spec.ts +++ b/tests/e2e/cli-web.spec.ts @@ -79,7 +79,7 @@ test("handles authenticated web RPC requests", async ({ request }) => { expect(payload.ok).toBe(true); expect(payload.value.name).toBe("Claude Code Router"); expect(payload.value.configDir).toContain(current.testHome); - expect(payload.value.appConfigDbFile).toContain("config.sqlite"); + expect(payload.value.configDbFile).toContain("config.sqlite"); expect(payload.value.usageDbFile).toContain("usage.sqlite"); }); diff --git a/tests/e2e/electron-cdp.spec.ts b/tests/e2e/electron-cdp.spec.ts new file mode 100644 index 00000000..309043c1 --- /dev/null +++ b/tests/e2e/electron-cdp.spec.ts @@ -0,0 +1,440 @@ +import { chromium, expect, test, type Browser, type Page } from "@playwright/test"; +import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; +import electronModule from "electron"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import type { Readable } from "node:stream"; + +const projectRoot = path.resolve(__dirname, "..", ".."); +const host = "127.0.0.1"; +const startupTimeoutMs = 30_000; +const shutdownTimeoutMs = 10_000; +const hookTimeoutMs = (startupTimeoutMs * 3) + shutdownTimeoutMs + 20_000; +const electronExecutable = electronModule as unknown as string; + +type ElectronCdpRuntime = { + browser: Browser; + cdpUrl: string; + child: ElectronChild; + gatewayPort: number; + mainPage: Page; + output: ProcessOutput; + testHome: string; +}; + +type ElectronChild = ChildProcessByStdio; +type ProcessOutput = { + stderr: string; + stdout: string; +}; + +let runtime: ElectronCdpRuntime | undefined; + +test.skip( + process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY, + "Electron CDP E2E requires a desktop session on Linux." +); + +test.beforeAll(async () => { + test.setTimeout(hookTimeoutMs); + runtime = await startElectronOverCdp(); +}); + +test.afterAll(async () => { + test.setTimeout(shutdownTimeoutMs + 5_000); + const current = runtime; + runtime = undefined; + if (!current) { + return; + } + + try { + await stopElectronOverCdp(current); + } finally { + removeElectronTestHome(current.testHome); + } +}); + +test("exposes the desktop renderer through CDP", async () => { + const current = requireRuntime(); + expect(current.cdpUrl).toBe(`http://${host}:${new URL(current.cdpUrl).port}`); + expect(current.mainPage.url()).toContain("/pages/home/index.html"); + await expect(current.mainPage).toHaveTitle("Claude Code Router"); +}); + +test("loads the Electron preload bridge and IPC contract", async () => { + const current = requireRuntime(); + const page = current.mainPage; + + await page.waitForFunction(() => { + return Boolean(window.ccr?.getAppInfo && document.querySelector("#root")?.childElementCount); + }); + + const appInfo = await page.evaluate(async () => window.ccr?.getAppInfo()); + expect(appInfo?.desktop).toBe(true); + expect(appInfo?.name).toBe("Claude Code Router"); + expect(appInfo?.configDir).toContain(current.testHome); + expect(appInfo?.configDbFile).toContain("config.sqlite"); + + const config = await page.evaluate(async () => window.ccr?.getConfig()); + expect(config?.gateway.enabled).toBe(false); + expect(config?.gateway.port).toBe(current.gatewayPort); + expect(config?.routerEndpoint).toBe(`http://${host}:${current.gatewayPort}`); + expect(config?.APIKEYS.some((item) => item.key === "sk-electron-cdp-legacy")).toBe(true); + + const gatewayStatus = await page.evaluate(async () => window.ccr?.getGatewayStatus()); + expect(gatewayStatus?.state).toBe("stopped"); + + const configDir = path.join(current.testHome, ".claude-code-router"); + const configDbFile = path.join(configDir, "config.sqlite"); + expect(existsSync(configDbFile)).toBe(true); + expect(existsSync(path.join(configDir, "config.json"))).toBe(false); + expect(existsSync(path.join(current.testHome, "user-data", "api-keys.sqlite"))).toBe(false); + + const tables = readSqliteRows<{ name: string }>( + configDbFile, + "SELECT name FROM sqlite_master WHERE type = 'table'" + ); + expect(tables.map((item) => item.name)).toEqual(expect.arrayContaining([ + "api_keys", + "app_config", + "config_schema_migrations", + "legacy_storage_backups", + "runtime_state" + ])); +}); + +test("starts the managed gateway from IPC without runtime config JSON", async () => { + const current = requireRuntime(); + const status = await current.mainPage.evaluate(async () => { + const config = await window.ccr?.getConfig(); + if (!config) { + throw new Error("Config bridge is unavailable."); + } + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "e2e-provider-key", + id: "electron-cdp-provider", + models: ["electron-cdp-model"], + name: "Electron CDP Provider", + type: "openai_chat_completions" + }]; + config.preferredProvider = "Electron CDP Provider"; + config.gateway.enabled = true; + await window.ccr?.saveConfig(config, { applyProfile: false }); + return window.ccr?.getGatewayStatus(); + }); + + expect(status?.state).toBe("running"); + const configDir = path.join(current.testHome, ".claude-code-router"); + expect(existsSync(path.join(configDir, "gateway.config.json"))).toBe(false); + expect(existsSync(path.join(configDir, "gateway-runtime.json"))).toBe(false); + + const runtimeMarker = readGatewayRuntimeMarker(path.join(configDir, "config.sqlite")); + expect(runtimeMarker).toMatchObject({ + pid: expect.any(Number), + runtimeId: expect.any(String) + }); + + await current.mainPage.evaluate(() => window.ccr?.stopGateway()); + + expect(readGatewayRuntimeMarker(path.join(configDir, "config.sqlite"))).toBeUndefined(); +}); + +async function startElectronOverCdp(): Promise { + const cdpPort = await findAvailablePort(); + const gatewayPort = await findAvailablePort(); + const gatewayCorePort = await findAvailablePort(); + const proxyPort = await findAvailablePort(); + const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-electron-cdp-home-")); + const output: ProcessOutput = { stderr: "", stdout: "" }; + let browser: Browser | undefined; + let child: ElectronChild | undefined; + + try { + writeElectronE2eConfig(testHome, { + gatewayCorePort, + gatewayPort, + proxyPort + }); + + child = spawn(electronExecutable, [ + "--disable-gpu", + "--no-sandbox", + "--remote-allow-origins=*", + `--remote-debugging-address=${host}`, + `--remote-debugging-port=${cdpPort}`, + projectRoot + ], { + cwd: projectRoot, + env: { + ...process.env, + CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), + CCR_INTERNAL_HOME_DIR: testHome, + CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), + ELECTRON_DISABLE_SECURITY_WARNINGS: "1", + HOME: testHome, + NODE_ENV: "test" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output.stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + output.stderr += chunk; + }); + child.on("error", (error) => { + output.stderr += `\nElectron process error: ${formatError(error)}`; + }); + + const cdpUrl = await waitForCdpUrl(child, output, cdpPort); + browser = await chromium.connectOverCDP(cdpUrl, { timeout: startupTimeoutMs }); + const mainPage = await waitForMainPage(browser, child, output); + + return { + browser, + cdpUrl, + child, + gatewayPort, + mainPage, + output, + testHome + }; + } catch (error) { + await browser?.close().catch(() => undefined); + if (child) { + await stopElectronProcess(child).catch(() => undefined); + } + removeElectronTestHome(testHome); + throw error; + } +} + +async function stopElectronOverCdp(current: ElectronCdpRuntime): Promise { + await current.mainPage.evaluate(() => window.ccr?.quitApp()).catch(() => undefined); + await current.browser.close().catch(() => undefined); + await stopElectronProcess(current.child); +} + +function writeElectronE2eConfig( + testHome: string, + ports: { gatewayCorePort: number; gatewayPort: number; proxyPort: number } +): void { + const configDir = path.join(testHome, ".claude-code-router"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + HOST: host, + PORT: ports.gatewayPort, + Providers: [], + autoStart: false, + gateway: { + corePort: ports.gatewayCorePort, + enabled: false, + host, + port: ports.gatewayPort + }, + profile: { + enabled: false, + profiles: [] + }, + proxy: { + enabled: false, + port: ports.proxyPort, + systemProxy: false + }, + routerEndpoint: `http://${host}:${ports.gatewayPort}` + }, null, 2)); + + const dataDir = path.join(testHome, "user-data"); + mkdirSync(dataDir, { recursive: true }); + createLegacyApiKeyDatabase(path.join(dataDir, "api-keys.sqlite")); +} + +function createLegacyApiKeyDatabase(file: string): void { + runElectronNode(` + const Database = require("better-sqlite3"); + const database = new Database(process.env.CCR_E2E_SQLITE_FILE); + database.exec(\` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + encrypted_key TEXT NOT NULL, + encryption TEXT NOT NULL DEFAULT 'plain', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL DEFAULT '', + limits_json TEXT NOT NULL DEFAULT '' + ) + \`); + database.prepare(\` + INSERT INTO api_keys ( + id, name, encrypted_key, encryption, created_at, expires_at, limits_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + \`).run( + "legacy-electron-cdp", + "Legacy Electron CDP", + "sk-electron-cdp-legacy", + "plain", + new Date(0).toISOString(), + "", + "" + ); + database.close(); + `, file); +} + +function readGatewayRuntimeMarker(file: string): Record | undefined { + const rows = readSqliteRows<{ value_json: string }>( + file, + "SELECT value_json FROM runtime_state WHERE key = 'gateway'" + ); + return rows[0] ? JSON.parse(rows[0].value_json) as Record : undefined; +} + +function readSqliteRows(file: string, query: string): Row[] { + const output = runElectronNode(` + const Database = require("better-sqlite3"); + const database = new Database(process.env.CCR_E2E_SQLITE_FILE, { readonly: true }); + const rows = database.prepare(process.env.CCR_E2E_SQLITE_QUERY).all(); + database.close(); + process.stdout.write(JSON.stringify(rows)); + `, file, query); + return JSON.parse(output || "[]") as Row[]; +} + +function runElectronNode(script: string, file: string, query = ""): string { + const result = spawnSync(electronExecutable, ["-e", script], { + cwd: projectRoot, + encoding: "utf8", + env: { + ...process.env, + CCR_E2E_SQLITE_FILE: file, + CCR_E2E_SQLITE_QUERY: query, + ELECTRON_RUN_AS_NODE: "1" + } + }); + if (result.status !== 0) { + throw new Error(`Electron SQLite helper failed:\n${result.stderr || result.stdout}`); + } + return result.stdout; +} + +async function waitForCdpUrl(child: ElectronChild, output: ProcessOutput, port: number): Promise { + const cdpUrl = `http://${host}:${port}`; + const deadline = Date.now() + startupTimeoutMs; + let lastError = ""; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw electronStartupError(`Electron exited before CDP became available with code ${child.exitCode}.`, output, lastError); + } + + try { + const response = await fetch(`${cdpUrl}/json/version`); + if (response.ok) { + return cdpUrl; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = formatError(error); + } + + await delay(100); + } + + throw electronStartupError(`Electron CDP endpoint did not start within ${startupTimeoutMs}ms.`, output, lastError); +} + +async function waitForMainPage(browser: Browser, child: ElectronChild, output: ProcessOutput): Promise { + const deadline = Date.now() + startupTimeoutMs; + let lastSeenUrl = ""; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw electronStartupError(`Electron exited before the main window was visible with code ${child.exitCode}.`, output, lastSeenUrl); + } + + for (const page of browser.contexts().flatMap((context) => context.pages())) { + const url = page.url(); + lastSeenUrl = url || lastSeenUrl; + if (url.includes("/pages/home/index.html")) { + await page.waitForLoadState("domcontentloaded", { + timeout: Math.max(1, deadline - Date.now()) + }); + return page; + } + } + + await delay(100); + } + + throw electronStartupError(`Electron main window was not exposed through CDP within ${startupTimeoutMs}ms.`, output, lastSeenUrl); +} + +async function stopElectronProcess(child: ElectronChild): Promise { + if (child.exitCode !== null) { + return; + } + + child.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, shutdownTimeoutMs); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function removeElectronTestHome(testHome: string): void { + try { + rmSync(testHome, { force: true, recursive: true }); + } catch (error) { + console.warn(`Failed to remove Electron E2E home ${testHome}: ${formatError(error)}`); + } +} + +async function findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, host, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + server.close(() => { + if (!port) { + reject(new Error("Failed to allocate a local test port.")); + return; + } + resolve(port); + }); + }); + }); +} + +function requireRuntime(): ElectronCdpRuntime { + if (!runtime) { + throw new Error("Electron CDP runtime was not started."); + } + return runtime; +} + +function electronStartupError(message: string, output: ProcessOutput, detail: string): Error { + return new Error(`${message}\nlast detail:\n${detail || "(none)"}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/system/docker-smoke.mjs b/tests/system/docker-smoke.mjs index 5a60066d..cf22ea2d 100644 --- a/tests/system/docker-smoke.mjs +++ b/tests/system/docker-smoke.mjs @@ -157,6 +157,12 @@ try { assert.equal(runningGatewayHealth.status, 200); assert.equal((await runningGatewayHealth.json()).status, "running"); + const configStorage = dockerConfigStorage(containerId); + assert.equal(configStorage.configDatabase, true); + assert.equal(configStorage.legacyConfigJson, false); + assert.equal(configStorage.gatewayConfigJson, false); + assert.equal(configStorage.gatewayRuntimeJson, false); + console.log(`Docker smoke test passed for ${imageName} on ${baseUrl}`); } finally { if (containerStarted) { @@ -171,7 +177,6 @@ function seedLegacyDockerConfig(volume) { const script = ` const fs = require("node:fs"); const path = require("node:path"); -const Database = require("better-sqlite3"); const configDir = "/data/.claude-code-router"; const config = { HOST: "0.0.0.0", @@ -189,10 +194,6 @@ const config = { fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(path.join(configDir, "app-data"), { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify(config, null, 2) + "\\n", { mode: 0o600 }); -const db = new Database(path.join(configDir, "config.sqlite")); -db.exec("create table app_config (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL)"); -db.prepare("insert into app_config (key, value_json, updated_at) values (?, ?, ?)").run("default", JSON.stringify(config), new Date().toISOString()); -db.close(); `; run("docker", ["run", "--rm", "-v", `${volume}:/data`, "--entrypoint", "node", imageName, "-e", script]); } @@ -213,6 +214,21 @@ function dockerPorts(containerId) { return JSON.parse(raw); } +function dockerConfigStorage(containerId) { + const script = ` +const fs = require("node:fs"); +const path = require("node:path"); +const configDir = "/data/.claude-code-router"; +process.stdout.write(JSON.stringify({ + configDatabase: fs.existsSync(path.join(configDir, "config.sqlite")), + gatewayConfigJson: fs.existsSync(path.join(configDir, "gateway.config.json")), + gatewayRuntimeJson: fs.existsSync(path.join(configDir, "gateway-runtime.json")), + legacyConfigJson: fs.existsSync(path.join(configDir, "config.json")) +})); +`; + return JSON.parse(run("docker", ["exec", containerId, "node", "-e", script])); +} + async function waitFor(check, label) { const startedAt = Date.now(); let lastError; diff --git a/tsconfig.json b/tsconfig.json index 23a00ab3..c2e2e421 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "skipLibCheck": true, "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "baseUrl": ".", "paths": { "@/*": ["./packages/ui/src/*"],