mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-19 00:46:15 +00:00
Decouple Claude Design proxy from browser app
This commit is contained in:
parent
27ffe73c6d
commit
31e0756ae4
10 changed files with 351 additions and 186 deletions
|
|
@ -31,7 +31,6 @@ const BOOTSTRAP_ROUTE_PATHS = ["/_bootstrap", "/api/bootstrap", "/edge-api/boots
|
|||
const REQUIRED_ROUTE_PATHS = ["/", OMELETTE_RPC_PATH_PREFIX, "/v1/design", ...CLAUDE_APP_SPA_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_ROUTE_PATHS = ["/design", "/v1/design", "/api", "/organizations", "/cdn-cgi", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const FALLBACK_ROUTE_PATHS = ["/app-unavailable-in-region", "/cdn-cgi", "/design", ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_DIRECT_GATEWAY_ROUTE_PATHS = ["/design", "/v1/design", "/api", "/organizations", "/assets", "/cdn-cgi", "/app-unavailable-in-region", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_SCRIPT_PATH = "/design/assets/v1/index-C0BEUHEw.js";
|
||||
const DEFAULT_STYLE_PATH = "/design/assets/v1/index-CqhNJH1o.css";
|
||||
const DEFAULT_DESIGN_CRITICAL_MODULE_PRELOAD_PATHS = [
|
||||
|
|
@ -255,17 +254,6 @@ module.exports = {
|
|||
const routing = normalizeClaudeDesignRouting(options.routing, options);
|
||||
const upstreamOrigins = normalizeUpstreamOrigins(options.upstreamOrigins, upstreamOrigin);
|
||||
const me = normalizeMe(options.me, frontendDefaultModel, gatewayModelPresets);
|
||||
const browserAppUrl = claudeDesignBrowserAppUrl(routeHost, usingClaudeAppAssets);
|
||||
|
||||
ctx.registerApp({
|
||||
description: usingClaudeAppAssets
|
||||
? "Open Claude Design UI through the Claude App shell and CCR gateway."
|
||||
: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: browserAppUrl
|
||||
});
|
||||
|
||||
const store = await ctx.openSqliteStore({
|
||||
filename: "claude-design.sqlite",
|
||||
migrate(database) {
|
||||
|
|
@ -394,20 +382,6 @@ module.exports = {
|
|||
});
|
||||
}
|
||||
|
||||
if (options.directGatewayRoutes !== false) {
|
||||
for (const pathPrefix of normalizeDirectGatewayRoutePaths(options.directGatewayPaths)) {
|
||||
ctx.registerGatewayRoute({
|
||||
auth: "none",
|
||||
handler(request, response) {
|
||||
return handleMockRequest(runtime, request, response);
|
||||
},
|
||||
id: `claude-design-ui-${sanitizeRouteId(pathPrefix)}`,
|
||||
methods: ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"],
|
||||
pathPrefix
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.registerProxyRoute({
|
||||
host: routeHost,
|
||||
id: "claude-design-proxy",
|
||||
|
|
@ -1093,27 +1067,6 @@ function normalizeFallbackRouteHosts(value, primaryHost) {
|
|||
return hosts;
|
||||
}
|
||||
|
||||
function claudeDesignBrowserAppUrl(routeHost, usingClaudeAppAssets) {
|
||||
if (!usingClaudeAppAssets) {
|
||||
return `https://${routeHost}/design`;
|
||||
}
|
||||
const targetPath = withClaudeAppDesignIframeMarker("/design");
|
||||
return `https://${routeHost}${CLAUDE_APP_DESIGN_SHELL_PATH}?${CLAUDE_APP_DESIGN_PATH_QUERY}=${encodeURIComponent(targetPath)}`;
|
||||
}
|
||||
|
||||
function normalizeDirectGatewayRoutePaths(value) {
|
||||
const configured = stringArray(value);
|
||||
const paths = configured?.length ? configured : DEFAULT_DIRECT_GATEWAY_ROUTE_PATHS;
|
||||
return Array.from(new Set(paths.map(normalizePath).filter(Boolean).filter((path) => path !== "/")));
|
||||
}
|
||||
|
||||
function sanitizeRouteId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|| "root";
|
||||
}
|
||||
|
||||
async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
||||
const current = runtime.designIndexAssets || {
|
||||
checkedAt: 0,
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@
|
|||
"assetPassthrough": false,
|
||||
"assetAutoUpdate": true,
|
||||
"autoAnswerQuestions": true,
|
||||
// Optional. Keep true so Claude App can iframe the local CCR-served
|
||||
// Claude Design UI at http://127.0.0.1:<gatewayPort>/design.
|
||||
"directGatewayRoutes": true,
|
||||
// Optional. When assetDir is omitted, the plugin will auto-detect Claude Desktop's
|
||||
// bundled ion-dist assets, e.g. /Applications/Claude.app/Contents/Resources/ion-dist.
|
||||
// Set claudeAppAssets to false to disable that auto-detection.
|
||||
|
|
|
|||
|
|
@ -555,8 +555,10 @@ function resolveInstalledBrowserApps(config: AppConfig, runtimeApps: InstalledBr
|
|||
if (plugin.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const configuredApps = plugin.apps?.length ? plugin.apps : defaultBrowserAppsForPlugin(plugin);
|
||||
for (const app of configuredApps) {
|
||||
if (plugin.id === "claude-design") {
|
||||
continue;
|
||||
}
|
||||
for (const app of plugin.apps ?? []) {
|
||||
const normalized = normalizeConfiguredBrowserApp(plugin.id, app, apps.size + 1);
|
||||
if (normalized) {
|
||||
apps.set(`${normalized.pluginId}:${normalized.id}`, normalized);
|
||||
|
|
@ -564,39 +566,14 @@ function resolveInstalledBrowserApps(config: AppConfig, runtimeApps: InstalledBr
|
|||
}
|
||||
}
|
||||
for (const app of runtimeApps) {
|
||||
if (app.pluginId === "claude-design") {
|
||||
continue;
|
||||
}
|
||||
apps.set(`${app.pluginId}:${app.id}`, { ...app });
|
||||
}
|
||||
return [...apps.values()];
|
||||
}
|
||||
|
||||
function defaultBrowserAppsForPlugin(plugin: AppConfig["plugins"][number]): GatewayPluginAppConfig[] {
|
||||
if (plugin.id !== "claude-design") {
|
||||
return [];
|
||||
}
|
||||
const config = isPlainRecord(plugin.config) ? plugin.config : {};
|
||||
const host = stringValue(config.host) || "claude.ai";
|
||||
const url = usesClaudeAppDesignShell(config)
|
||||
? claudeAppDesignShellUrl(host)
|
||||
: `https://${host}/design`;
|
||||
return [
|
||||
{
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function usesClaudeAppDesignShell(config: Record<string, unknown>): boolean {
|
||||
return config.claudeAppAssets !== false && !stringValue(config.assetDir);
|
||||
}
|
||||
|
||||
function claudeAppDesignShellUrl(host: string): string {
|
||||
const path = encodeURIComponent("/design?__ccr_design_iframe=1");
|
||||
return `https://${host}/desktop-design?path=${path}`;
|
||||
}
|
||||
|
||||
function normalizeConfiguredBrowserApp(pluginId: string, app: GatewayPluginAppConfig, index: number): InstalledBrowserApp | undefined {
|
||||
const name = app.name?.trim();
|
||||
const url = app.url?.trim();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type ClaudeAppCdpLogger = Pick<Console, "info" | "warn">;
|
|||
type ClaudeAppDesignCdpOptions = {
|
||||
cdpPort?: number;
|
||||
designUrl?: string;
|
||||
enabled?: boolean;
|
||||
logger?: ClaudeAppCdpLogger;
|
||||
};
|
||||
|
||||
|
|
@ -45,12 +46,16 @@ const claudeAppDesignCdpConnectTimeoutMs = 15_000;
|
|||
const claudeAppDesignCdpKeepAliveMs = 45_000;
|
||||
const claudeAppDesignCdpPollIntervalMs = 250;
|
||||
|
||||
export function shouldEnableClaudeAppDesignCdp(): boolean {
|
||||
return process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase() === "true";
|
||||
export function shouldEnableClaudeAppDesignCdp(enabledByConfig = false): boolean {
|
||||
const configured = process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase();
|
||||
if (configured === "false" || configured === "0" || configured === "off") {
|
||||
return false;
|
||||
}
|
||||
return enabledByConfig || configured === "true" || configured === "1" || configured === "on";
|
||||
}
|
||||
|
||||
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console): Promise<number | undefined> {
|
||||
if (!shouldEnableClaudeAppDesignCdp()) {
|
||||
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console, enabledByConfig = false): Promise<number | undefined> {
|
||||
if (!shouldEnableClaudeAppDesignCdp(enabledByConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
const configured = Number(process.env.CCR_CLAUDE_APP_CDP_PORT);
|
||||
|
|
@ -70,7 +75,7 @@ export function prepareClaudeAppCdpUserDataDir(userDataDir: string): void {
|
|||
}
|
||||
|
||||
export function scheduleClaudeAppDesignCdp(options: ClaudeAppDesignCdpOptions): void {
|
||||
if (!shouldEnableClaudeAppDesignCdp()) {
|
||||
if (!shouldEnableClaudeAppDesignCdp(options.enabled === true)) {
|
||||
return;
|
||||
}
|
||||
const logger = options.logger || console;
|
||||
|
|
@ -87,7 +92,11 @@ export function scheduleClaudeAppDesignCdp(options: ClaudeAppDesignCdpOptions):
|
|||
});
|
||||
}
|
||||
|
||||
async function forceOpenClaudeAppDesignViaCdp(options: Required<ClaudeAppDesignCdpOptions>): Promise<void> {
|
||||
async function forceOpenClaudeAppDesignViaCdp(options: {
|
||||
cdpPort: number;
|
||||
designUrl: string;
|
||||
logger: ClaudeAppCdpLogger;
|
||||
}): Promise<void> {
|
||||
const target = await waitForClaudeAppPageTarget(options.cdpPort, claudeAppDesignCdpConnectTimeoutMs);
|
||||
if (!target.webSocketDebuggerUrl) {
|
||||
throw new Error(`Claude App CDP page target was not available on port ${options.cdpPort}.`);
|
||||
|
|
@ -273,7 +282,7 @@ function normalizeClaudeAppDesignUrl(value: string | undefined): string {
|
|||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!isLocalHttpUrl(url)) {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return "";
|
||||
}
|
||||
url.pathname = normalizeDesignPath(url.pathname);
|
||||
|
|
@ -284,17 +293,6 @@ function normalizeClaudeAppDesignUrl(value: string | undefined): string {
|
|||
}
|
||||
}
|
||||
|
||||
function isLocalHttpUrl(url: URL): boolean {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
return hostname === "127.0.0.1" ||
|
||||
hostname === "localhost" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]";
|
||||
}
|
||||
|
||||
function normalizeDesignPath(value: string): string {
|
||||
if (!value || value === "/") {
|
||||
return "/design";
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import { app } from "electron";
|
|||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { saveAppConfig } from "./config";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { buildClaudeAppGatewayInferenceModels, type ClaudeAppGatewayModelRouteOptions } from "../shared/claude-app-gateway";
|
||||
import {
|
||||
buildClaudeAppGatewayInferenceModels,
|
||||
type ClaudeAppGatewayInferenceModel,
|
||||
type ClaudeAppGatewayModelRouteOptions
|
||||
} from "../shared/claude-app-gateway";
|
||||
import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app";
|
||||
import { findModelCatalogEntry } from "../server/gateway/model-catalog";
|
||||
|
||||
|
|
@ -16,6 +20,7 @@ const CLAUDE_APP_CONFIG_LIBRARY_DIR = "configLibrary";
|
|||
const CLAUDE_APP_CONFIG_META_FILE = "_meta.json";
|
||||
const CLAUDE_APP_GATEWAY_BACKUP_FILE = path.join(CONFIGDIR, "claude-app-gateway-backup.json");
|
||||
const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOptions = {
|
||||
displayName: (model) => findModelCatalogEntry(model)?.displayName,
|
||||
supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext)
|
||||
};
|
||||
|
||||
|
|
@ -24,7 +29,9 @@ type ClaudeAppGatewayConfig = {
|
|||
inferenceGatewayApiKey: string;
|
||||
inferenceGatewayAuthScheme: "x-api-key";
|
||||
inferenceGatewayBaseUrl: string;
|
||||
inferenceModels: Array<{ displayName?: string; name: string }>;
|
||||
inferenceModels: ClaudeAppGatewayInferenceModel[];
|
||||
inferenceModelsUpdatedAt: string;
|
||||
inferenceModelsVersion: string;
|
||||
inferenceProvider: "gateway";
|
||||
modelDiscoveryEnabled: true;
|
||||
unstableDisableModelVerification: true;
|
||||
|
|
@ -60,6 +67,7 @@ type ClaudeAppGatewayBackup = {
|
|||
type ClaudeAppGatewayApplyOptions = {
|
||||
backup?: boolean;
|
||||
dataDir?: string;
|
||||
refreshModelDiscoveryCache?: boolean;
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewaySyncResult = {
|
||||
|
|
@ -91,12 +99,15 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp
|
|||
const endpoint = gatewayEndpoint(state.config);
|
||||
const models = buildClaudeAppGatewayInferenceModels(state.config, claudeAppGatewayModelRouteOptions);
|
||||
const model = models[0]?.name ?? "";
|
||||
const modelsVersion = claudeAppGatewayModelsVersion(endpoint, models);
|
||||
const gatewayConfig: ClaudeAppGatewayConfig = {
|
||||
inferenceCredentialKind: "static",
|
||||
inferenceGatewayApiKey: state.apiKey,
|
||||
inferenceGatewayAuthScheme: "x-api-key",
|
||||
inferenceGatewayBaseUrl: endpoint,
|
||||
inferenceModels: models,
|
||||
inferenceModelsUpdatedAt: new Date().toISOString(),
|
||||
inferenceModelsVersion: modelsVersion,
|
||||
inferenceProvider: "gateway",
|
||||
modelDiscoveryEnabled: true,
|
||||
unstableDisableModelVerification: true
|
||||
|
|
@ -109,6 +120,9 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp
|
|||
writeJsonFile(paths.configLibraryFile, gatewayConfig);
|
||||
applyClaudeAppConfigMeta(paths.metaFile);
|
||||
applyClaudeAppDeploymentMode(paths.rootConfigFile);
|
||||
if (options.refreshModelDiscoveryCache) {
|
||||
refreshClaudeAppModelDiscoveryCache(paths.dataDir);
|
||||
}
|
||||
|
||||
return {
|
||||
config: state.config,
|
||||
|
|
@ -125,6 +139,28 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp
|
|||
};
|
||||
}
|
||||
|
||||
function claudeAppGatewayModelsVersion(endpoint: string, models: ClaudeAppGatewayInferenceModel[]): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify({ endpoint, models }))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function refreshClaudeAppModelDiscoveryCache(dataDir: string): void {
|
||||
for (const relativePath of [
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"DawnCache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
path.join("Service Worker", "CacheStorage"),
|
||||
path.join("Service Worker", "ScriptCache")
|
||||
]) {
|
||||
rmSync(path.join(dataDir, relativePath), { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreClaudeAppGatewayConfig(): void {
|
||||
const backup = readClaudeAppGatewayBackup();
|
||||
if (!backup) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type ClaudeAppLaunchResult = {
|
|||
child: ChildProcess;
|
||||
command: string;
|
||||
cdpPort?: number;
|
||||
claudeDesignProxy?: boolean;
|
||||
pid?: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
|
@ -47,7 +48,8 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
prepareClaudeAppCdpUserDataDir(userDataDir);
|
||||
const cdpPort = await reserveClaudeAppCdpPort(console);
|
||||
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
|
||||
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
|
|
@ -62,7 +64,8 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const designUrl = claudeAppDesignUrl(config);
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort), {
|
||||
const proxyUrl = claudeAppProxyUrl(config);
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort, proxyUrl), {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: "ignore"
|
||||
|
|
@ -71,11 +74,13 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
|||
scheduleClaudeAppDesignCdp({
|
||||
cdpPort,
|
||||
designUrl,
|
||||
enabled: shouldOpenDesign,
|
||||
logger: console
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
claudeDesignProxy: Boolean(proxyUrl),
|
||||
command: lookup.executable,
|
||||
...(cdpPort ? { cdpPort } : {}),
|
||||
pid: child.pid,
|
||||
|
|
@ -88,8 +93,26 @@ export function resolveClaudeAppProfileUserDataDir(configDir: string, profile: P
|
|||
return claudeElectronUserDataDir(path.dirname(settingsFile), profile);
|
||||
}
|
||||
|
||||
function shouldOpenClaudeAppDesign(config: AppConfig | undefined): boolean {
|
||||
return Boolean(claudeDesignPluginConfig(config));
|
||||
}
|
||||
|
||||
function claudeAppDesignUrl(config: AppConfig | undefined): string | undefined {
|
||||
if (!config) {
|
||||
const plugin = claudeDesignPluginConfig(config);
|
||||
if (!plugin) {
|
||||
return undefined;
|
||||
}
|
||||
const options = isRecord(plugin.config) ? plugin.config : {};
|
||||
const host = typeof options.host === "string" && options.host.trim() ? options.host.trim() : "claude.ai";
|
||||
return `https://${host}/design`;
|
||||
}
|
||||
|
||||
function claudeDesignPluginConfig(config: AppConfig | undefined): AppConfig["plugins"][number] | undefined {
|
||||
return config?.plugins.find((plugin) => plugin.enabled !== false && plugin.id === "claude-design");
|
||||
}
|
||||
|
||||
function claudeAppProxyUrl(config: AppConfig | undefined): string | undefined {
|
||||
if (!config?.proxy?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const port = Number.isInteger(config.gateway?.port) && config.gateway.port > 0
|
||||
|
|
@ -101,7 +124,7 @@ function claudeAppDesignUrl(config: AppConfig | undefined): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
const host = formatLoopbackGatewayHost(config.gateway?.host || "127.0.0.1");
|
||||
return `http://${host}:${port}/design`;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function formatLoopbackGatewayHost(host: string): string {
|
||||
|
|
@ -115,7 +138,7 @@ function formatLoopbackGatewayHost(host: string): string {
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
function claudeElectronArgs(userDataDir: string, cdpPort?: number): string[] {
|
||||
function claudeElectronArgs(userDataDir: string, cdpPort?: number, proxyUrl?: string): string[] {
|
||||
return [
|
||||
...(cdpPort
|
||||
? [
|
||||
|
|
@ -123,6 +146,12 @@ function claudeElectronArgs(userDataDir: string, cdpPort?: number): string[] {
|
|||
"--remote-debugging-address=127.0.0.1"
|
||||
]
|
||||
: []),
|
||||
...(proxyUrl
|
||||
? [
|
||||
`--proxy-server=${proxyUrl}`,
|
||||
"--proxy-bypass-list=localhost;127.0.0.1;[::1]"
|
||||
]
|
||||
: []),
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-renderer-backgrounding",
|
||||
|
|
@ -287,6 +316,10 @@ function isEnvName(value: string): boolean {
|
|||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizeProfilePathSegment(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,9 @@ import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfi
|
|||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
apps: [
|
||||
{
|
||||
description: "Open Claude Design through the CCR browser proxy.",
|
||||
id: "claude-design",
|
||||
name: "Claude Design",
|
||||
url: "https://claude.ai/design"
|
||||
}
|
||||
],
|
||||
capabilities: ["Wrapper runtime", "Browser app", "Claude Design", "Model routing"],
|
||||
capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"],
|
||||
dependencies: [],
|
||||
description: "Routes Claude Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.",
|
||||
id: "claude-design",
|
||||
modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"),
|
||||
name: "Claude Design"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ let claudeAppBotWorkerProfileId: string | undefined;
|
|||
|
||||
type ProfileAppLaunchResult = {
|
||||
child: ChildProcess;
|
||||
claudeDesignProxy?: boolean;
|
||||
command: string;
|
||||
pidIsLauncher?: boolean;
|
||||
pid?: number;
|
||||
|
|
@ -29,6 +30,7 @@ type ProfileAppLaunchResult = {
|
|||
|
||||
type RunningProfileApp = ProfileRuntimeEntry & {
|
||||
child: ChildProcess;
|
||||
claudeDesignProxy?: boolean;
|
||||
command: string;
|
||||
pidIsLauncher?: boolean;
|
||||
spawnError?: string;
|
||||
|
|
@ -129,22 +131,30 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
|
|||
}
|
||||
|
||||
async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
|
||||
const profileGatewayConfig = claudeAppGatewayConfigFor(config, profile);
|
||||
const existing = runningProfileApp(profile.id, "app");
|
||||
if (existing) {
|
||||
activateProfileAppWindow(existing);
|
||||
return {
|
||||
message: `Claude App is already running with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface: "app"
|
||||
};
|
||||
if (!claudeAppDesignProxyRequired(profileGatewayConfig) || existing.claudeDesignProxy) {
|
||||
activateProfileAppWindow(existing);
|
||||
return {
|
||||
message: `Claude App is already running with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
const stopped = await stopRunningProfileApp(profileRuntimeKey(profile.id, "app"), existing);
|
||||
if (!stopped) {
|
||||
throw new Error("Claude App is already running without the Claude Design proxy. Close Claude App and try again.");
|
||||
}
|
||||
stopClaudeAppBotWorker(profile.id);
|
||||
}
|
||||
|
||||
const profileGatewayConfig = profileGatewayConfigFor(config, profile);
|
||||
applyClaudeAppGatewayConfig(profileGatewayConfig);
|
||||
applyClaudeAppGatewayConfig(profileGatewayConfig, {
|
||||
backup: false,
|
||||
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile)
|
||||
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile),
|
||||
refreshModelDiscoveryCache: true
|
||||
});
|
||||
await ensureGatewayConfigRunning(profileGatewayConfig, "Claude App");
|
||||
const entry = registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig));
|
||||
|
|
@ -209,6 +219,26 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof f
|
|||
};
|
||||
}
|
||||
|
||||
function claudeAppGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
|
||||
const profileGatewayConfig = profileGatewayConfigFor(config, profile);
|
||||
if (!claudeAppDesignProxyRequired(profileGatewayConfig)) {
|
||||
return profileGatewayConfig;
|
||||
}
|
||||
return {
|
||||
...profileGatewayConfig,
|
||||
proxy: {
|
||||
...profileGatewayConfig.proxy,
|
||||
enabled: true,
|
||||
mode: "transparent",
|
||||
systemProxy: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function claudeAppDesignProxyRequired(config: AppConfig): boolean {
|
||||
return config.plugins.some((plugin) => plugin.enabled !== false && plugin.id === "claude-design");
|
||||
}
|
||||
|
||||
export function getProfileRuntimeStatus(): ProfileRuntimeStatus {
|
||||
cleanupExitedProfileApps();
|
||||
return {
|
||||
|
|
@ -276,6 +306,7 @@ function registerProfileApp(
|
|||
const entry: RunningProfileApp = {
|
||||
agent: profile.agent,
|
||||
child: launch.child,
|
||||
claudeDesignProxy: launch.claudeDesignProxy,
|
||||
command: launch.command,
|
||||
pid: launch.pid,
|
||||
pidIsLauncher: launch.pidIsLauncher,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "bra
|
|||
const fusionModelProviderName = "Fusion";
|
||||
const claudeCodeOneMillionContextSuffix = "[1m]";
|
||||
const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOptions = {
|
||||
displayName: (model) => findModelCatalogEntry(model)?.displayName,
|
||||
supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext)
|
||||
};
|
||||
|
||||
|
|
@ -580,8 +581,11 @@ class GatewayService {
|
|||
if (shouldServeGatewayModelsResponse(method, path)) {
|
||||
const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`;
|
||||
const modelHeaders = new Headers({
|
||||
"cache-control": "no-store, max-age=0",
|
||||
"content-length": String(Buffer.byteLength(responseText)),
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"expires": "0",
|
||||
"pragma": "no-cache"
|
||||
});
|
||||
response.writeHead(200, Object.fromEntries(filteredResponseHeaders(modelHeaders)));
|
||||
response.end(responseText);
|
||||
|
|
@ -3271,12 +3275,10 @@ function prepareClaudeAppFallbackModelRequest(
|
|||
}
|
||||
|
||||
function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record<string, unknown> {
|
||||
if (isClaudeAppApiKey(apiKey)) {
|
||||
if (isClaudeAppApiKey(apiKey) || isClaudeCodeUserAgent(headers)) {
|
||||
return createClaudeAppGatewayModelsResponse(config);
|
||||
}
|
||||
return isClaudeCodeUserAgent(headers)
|
||||
? createClaudeCodeModelsResponse(config)
|
||||
: createOpenAICompatibleGatewayModelsResponse(config);
|
||||
return createOpenAICompatibleGatewayModelsResponse(config);
|
||||
}
|
||||
|
||||
function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record<string, unknown> {
|
||||
|
|
@ -3303,7 +3305,7 @@ function createClaudeAppGatewayModelsResponse(config: AppConfig): Record<string,
|
|||
const data = routes.map((route) => {
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel);
|
||||
const catalogEntry = findModelCatalogEntry(catalogId);
|
||||
const maxInputTokens = claudeCodeEffectiveMaxInputTokens(catalogEntry, route.oneMillionContext);
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext);
|
||||
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
|
||||
return {
|
||||
id: route.id,
|
||||
|
|
@ -3311,17 +3313,10 @@ function createClaudeAppGatewayModelsResponse(config: AppConfig): Record<string,
|
|||
maxInputTokens,
|
||||
oneMillionContext: route.oneMillionContext
|
||||
}),
|
||||
catalog_id: catalogEntry?.id,
|
||||
context_window: maxInputTokens,
|
||||
created_at: "1970-01-01T00:00:00Z",
|
||||
display_name: route.displayName,
|
||||
input_modalities: catalogEntry?.modalities?.input ?? ["text"],
|
||||
max_input_tokens: maxInputTokens,
|
||||
max_tokens: maxOutputTokens,
|
||||
one_million_context_variant: route.oneMillionContext,
|
||||
output_modalities: catalogEntry?.modalities?.output ?? ["text"],
|
||||
supports_1m_context: Boolean(catalogEntry?.limits?.supports1MContext),
|
||||
target_model: route.targetModel,
|
||||
type: "model"
|
||||
};
|
||||
});
|
||||
|
|
@ -3340,7 +3335,7 @@ function createClaudeCodeModelsResponse(config: AppConfig): Record<string, unkno
|
|||
const claudeId = claudeCodeDiscoveryModelId(model.id);
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id);
|
||||
const catalogEntry = findModelCatalogEntry(catalogId);
|
||||
const maxInputTokens = claudeCodeEffectiveMaxInputTokens(catalogEntry, model.oneMillionContext);
|
||||
const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext);
|
||||
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
|
||||
return {
|
||||
id: claudeId,
|
||||
|
|
@ -3348,16 +3343,10 @@ function createClaudeCodeModelsResponse(config: AppConfig): Record<string, unkno
|
|||
maxInputTokens,
|
||||
oneMillionContext: model.oneMillionContext
|
||||
}),
|
||||
catalog_id: catalogEntry?.id,
|
||||
context_window: maxInputTokens,
|
||||
created_at: "1970-01-01T00:00:00Z",
|
||||
display_name: formatClaudeCodeModelDisplayName(claudeId, catalogEntry, model.oneMillionContext),
|
||||
input_modalities: catalogEntry?.modalities?.input ?? ["text"],
|
||||
max_input_tokens: maxInputTokens,
|
||||
max_tokens: maxOutputTokens,
|
||||
one_million_context_variant: model.oneMillionContext,
|
||||
output_modalities: catalogEntry?.modalities?.output ?? ["text"],
|
||||
supports_1m_context: Boolean(catalogEntry?.limits?.supports1MContext),
|
||||
type: "model"
|
||||
};
|
||||
});
|
||||
|
|
@ -3370,6 +3359,14 @@ function createClaudeCodeModelsResponse(config: AppConfig): Record<string, unkno
|
|||
};
|
||||
}
|
||||
|
||||
function claudeGatewayModelContextWindow(entry: ModelCatalogEntry | undefined, oneMillionContext: boolean): number {
|
||||
const contextWindow = modelCatalogMaxInputTokens(entry);
|
||||
if (contextWindow > 0) {
|
||||
return contextWindow;
|
||||
}
|
||||
return oneMillionContext ? 1_000_000 : 0;
|
||||
}
|
||||
|
||||
function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] {
|
||||
return buildGatewayDiscoverableModelIds(config);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,28 @@ import { normalizeProfileScopeValue } from "./app";
|
|||
|
||||
export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
|
||||
export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]";
|
||||
|
||||
const CLAUDE_APP_LEGACY_ROUTE_NAMES = [
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-5",
|
||||
"claude-sonnet-4",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-7-sonnet-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-5-haiku-latest",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4"
|
||||
];
|
||||
const CLAUDE_APP_ENCODED_ROUTE_PREFIX = "anthropic/claude-ccr-h";
|
||||
|
||||
export type ClaudeAppGatewayModelRoute = {
|
||||
displayName: string;
|
||||
id: string;
|
||||
legacyId?: string;
|
||||
legacyIds?: string[];
|
||||
oneMillionContext: boolean;
|
||||
targetModel: string;
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewayModelRouteOptions = {
|
||||
displayName?: (model: string) => string | undefined;
|
||||
supportsOneMillionContext?: (model: string) => boolean;
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewayInferenceModel = {
|
||||
labelOverride: string;
|
||||
name: string;
|
||||
supports1m?: true;
|
||||
};
|
||||
|
||||
export function inferClaudeAppGatewayTargetModel(config: Pick<AppConfig, "Router" | "profile">): string {
|
||||
return config.Router.default?.trim() ||
|
||||
inferGlobalClaudeProfileModel(config) ||
|
||||
|
|
@ -42,18 +36,39 @@ export function buildClaudeAppGatewayModelRoutes(
|
|||
options: ClaudeAppGatewayModelRouteOptions = {}
|
||||
): ClaudeAppGatewayModelRoute[] {
|
||||
const targetModels = claudeAppGatewayTargetModels(config);
|
||||
const displayNames = claudeAppGatewayDisplayNames(targetModels);
|
||||
return targetModels.map((rawTargetModel, index) => {
|
||||
const displayNames = claudeAppGatewayDisplayNames(targetModels, options);
|
||||
const configuredTargetKeys = new Set(targetModels.map((model) =>
|
||||
stripClaudeAppGatewayOneMillionContextSuffix(model).toLowerCase()
|
||||
));
|
||||
const usedRouteIds = new Set<string>();
|
||||
const seenTargets = new Set<string>();
|
||||
return targetModels.flatMap((rawTargetModel, index) => {
|
||||
const targetModel = stripClaudeAppGatewayOneMillionContextSuffix(rawTargetModel);
|
||||
const oneMillionContext = claudeAppGatewaySupportsOneMillionContext(rawTargetModel, options);
|
||||
const routeId = claudeAppGatewayRouteId(rawTargetModel);
|
||||
return {
|
||||
displayName: oneMillionContext ? `${displayNames[index]} (1M context)` : displayNames[index],
|
||||
const targetKey = `${targetModel.toLowerCase()}::${oneMillionContext ? "1m" : "base"}`;
|
||||
if (seenTargets.has(targetKey)) {
|
||||
return [];
|
||||
}
|
||||
seenTargets.add(targetKey);
|
||||
|
||||
const routeId = claudeAppGatewayRouteId(targetModel, usedRouteIds, configuredTargetKeys);
|
||||
if (!routeId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const legacyIds = uniqueStrings([
|
||||
claudeAppGatewayGeneratedRouteId(rawTargetModel),
|
||||
rawTargetModel === targetModel ? "" : claudeAppGatewayGeneratedRouteId(targetModel),
|
||||
targetModel
|
||||
]).filter((id) => id.toLowerCase() !== routeId.toLowerCase());
|
||||
return [{
|
||||
displayName: displayNames[index],
|
||||
id: routeId,
|
||||
legacyId: claudeAppGatewayLegacyRouteId(index),
|
||||
legacyId: legacyIds[0],
|
||||
legacyIds,
|
||||
oneMillionContext,
|
||||
targetModel
|
||||
};
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -63,24 +78,38 @@ export function resolveClaudeAppGatewayRouteModel(
|
|||
options: ClaudeAppGatewayModelRouteOptions = {}
|
||||
): string | undefined {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
const decodedRouteModel = decodeClaudeAppGatewayRouteId(normalized);
|
||||
if (decodedRouteModel) {
|
||||
const decodedTarget = claudeAppGatewayTargetModels(config).find((targetModel) =>
|
||||
stripClaudeAppGatewayOneMillionContextSuffix(targetModel).toLowerCase() === decodedRouteModel.toLowerCase()
|
||||
);
|
||||
if (decodedTarget) {
|
||||
return stripClaudeAppGatewayOneMillionContextSuffix(decodedTarget);
|
||||
}
|
||||
}
|
||||
|
||||
return buildClaudeAppGatewayModelRoutes(config, options).find((route) => {
|
||||
const routeId = route.id.toLowerCase();
|
||||
const legacyRouteId = route.legacyId?.toLowerCase();
|
||||
return routeId === normalized ||
|
||||
stripClaudeAppGatewayOneMillionContextSuffix(routeId).toLowerCase() === normalized ||
|
||||
legacyRouteId === normalized ||
|
||||
(legacyRouteId ? stripClaudeAppGatewayOneMillionContextSuffix(legacyRouteId).toLowerCase() === normalized : false);
|
||||
const normalizedBase = stripClaudeAppGatewayOneMillionContextSuffix(normalized).toLowerCase();
|
||||
return claudeAppGatewayRouteMatchIds(route).some((id) => {
|
||||
const routeId = id.toLowerCase();
|
||||
const routeBaseId = stripClaudeAppGatewayOneMillionContextSuffix(routeId).toLowerCase();
|
||||
return routeId === normalized || routeBaseId === normalizedBase;
|
||||
});
|
||||
})?.targetModel;
|
||||
}
|
||||
|
||||
export function buildClaudeAppGatewayInferenceModels(
|
||||
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
|
||||
options: ClaudeAppGatewayModelRouteOptions = {}
|
||||
): Array<{ displayName: string; name: string }> {
|
||||
): ClaudeAppGatewayInferenceModel[] {
|
||||
const routes = buildClaudeAppGatewayModelRoutes(config, options);
|
||||
return routes.length
|
||||
? routes.map((route) => ({ displayName: route.displayName, name: route.id }))
|
||||
: [{ displayName: "Claude Sonnet 4.5", name: CLAUDE_APP_FALLBACK_MODEL }];
|
||||
? routes.map((route) => ({
|
||||
labelOverride: route.displayName,
|
||||
name: route.id,
|
||||
...(route.oneMillionContext ? { supports1m: true as const } : {})
|
||||
}))
|
||||
: [{ labelOverride: "Claude Sonnet 4.5", name: CLAUDE_APP_FALLBACK_MODEL }];
|
||||
}
|
||||
|
||||
export function hasClaudeAppGatewayOneMillionContextSuffix(id: string): boolean {
|
||||
|
|
@ -158,25 +187,134 @@ function claudeAppGatewaySupportsOneMillionContext(
|
|||
Boolean(options.supportsOneMillionContext?.(baseModel));
|
||||
}
|
||||
|
||||
function claudeAppGatewayLegacyRouteId(index: number): string | undefined {
|
||||
return CLAUDE_APP_LEGACY_ROUTE_NAMES[index];
|
||||
function claudeAppGatewayRouteId(
|
||||
model: string,
|
||||
usedRouteIds: Set<string>,
|
||||
configuredTargetKeys: Set<string>
|
||||
): string | undefined {
|
||||
const targetModel = stripClaudeAppGatewayOneMillionContextSuffix(model);
|
||||
const candidates = [claudeAppGatewayNativeRouteId(targetModel), claudeAppGatewayEncodedRouteId(targetModel)];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const claimed = claimClaudeAppGatewayRouteId(candidate, usedRouteIds, configuredTargetKeys, targetModel);
|
||||
if (claimed) {
|
||||
return claimed;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 2; index < 100; index += 1) {
|
||||
const claimed = claimClaudeAppGatewayRouteId(
|
||||
claudeAppGatewayEncodedRouteId(targetModel, index),
|
||||
usedRouteIds,
|
||||
configuredTargetKeys,
|
||||
targetModel
|
||||
);
|
||||
if (claimed) {
|
||||
return claimed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function claudeAppGatewayRouteId(model: string): string {
|
||||
function claudeAppGatewayGeneratedRouteId(model: string): string {
|
||||
const normalized = model.trim();
|
||||
return normalized.toLowerCase().startsWith("claude-") ? normalized : `claude-${normalized}`;
|
||||
}
|
||||
|
||||
function claudeAppGatewayDisplayNames(models: string[]): string[] {
|
||||
const baseNames = models.map((model) => claudeAppGatewayBaseDisplayName(stripClaudeAppGatewayOneMillionContextSuffix(model)));
|
||||
function claudeAppGatewayNativeRouteId(model: string): string | undefined {
|
||||
const normalized = stripClaudeAppGatewayOneMillionContextSuffix(model);
|
||||
const lower = normalized.toLowerCase();
|
||||
if (!normalized.includes("/") && claudeAppGatewayNativeModelNameIsSafe(lower)) {
|
||||
return normalized;
|
||||
}
|
||||
if (lower.startsWith("anthropic/")) {
|
||||
const anthropicModel = normalized.slice("anthropic/".length);
|
||||
return claudeAppGatewayNativeModelNameIsSafe(anthropicModel.toLowerCase()) ? normalized : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function claudeAppGatewayNativeModelNameIsSafe(model: string): boolean {
|
||||
return /^claude-(?:3(?:-[57])?-(?:haiku|sonnet|opus)|(?:haiku|sonnet|opus|fable)(?:[-.:@0-9a-z]+)?|code(?:[-.:@0-9a-z]+)?)$/i.test(model);
|
||||
}
|
||||
|
||||
function claudeAppGatewayEncodedRouteId(model: string, variant?: number): string {
|
||||
const routePrefix = variant && variant > 1
|
||||
? `anthropic/claude-ccr${variant}-h`
|
||||
: CLAUDE_APP_ENCODED_ROUTE_PREFIX;
|
||||
return `${routePrefix}${encodeClaudeAppGatewayRouteModel(model)}`;
|
||||
}
|
||||
|
||||
function encodeClaudeAppGatewayRouteModel(model: string): string {
|
||||
return Buffer.from(stripClaudeAppGatewayOneMillionContextSuffix(model), "utf8").toString("hex");
|
||||
}
|
||||
|
||||
function decodeClaudeAppGatewayRouteId(routeId: string): string | undefined {
|
||||
const normalized = stripClaudeAppGatewayOneMillionContextSuffix(routeId).toLowerCase();
|
||||
const match = /^anthropic\/claude-ccr(?:\d+)?-h([0-9a-f]+)$/.exec(normalized);
|
||||
const encoded = match?.[1];
|
||||
if (!encoded || encoded.length % 2 !== 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(encoded, "hex").toString("utf8").trim();
|
||||
return decoded || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function claimClaudeAppGatewayRouteId(
|
||||
routeId: string | undefined,
|
||||
usedRouteIds: Set<string>,
|
||||
configuredTargetKeys: Set<string>,
|
||||
targetModel: string
|
||||
): string | undefined {
|
||||
const normalized = routeId ? stripClaudeAppGatewayOneMillionContextSuffix(routeId) : "";
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const key = normalized.toLowerCase();
|
||||
const targetKey = stripClaudeAppGatewayOneMillionContextSuffix(targetModel).toLowerCase();
|
||||
if (usedRouteIds.has(key) || (configuredTargetKeys.has(key) && key !== targetKey)) {
|
||||
return undefined;
|
||||
}
|
||||
usedRouteIds.add(key);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function claudeAppGatewayRouteMatchIds(route: ClaudeAppGatewayModelRoute): string[] {
|
||||
return uniqueStrings([route.id, route.legacyId, ...(route.legacyIds ?? [])]);
|
||||
}
|
||||
|
||||
function claudeAppGatewayDisplayNames(
|
||||
models: string[],
|
||||
options: ClaudeAppGatewayModelRouteOptions
|
||||
): string[] {
|
||||
const baseNames = models.map((model) => {
|
||||
const targetModel = stripClaudeAppGatewayOneMillionContextSuffix(model);
|
||||
return claudeAppGatewayDisplayNameWithProvider(
|
||||
targetModel,
|
||||
options.displayName?.(targetModel) ?? claudeAppGatewayBaseDisplayName(targetModel)
|
||||
);
|
||||
});
|
||||
const counts = new Map<string, number>();
|
||||
for (const baseName of baseNames) {
|
||||
const key = baseName.toLowerCase();
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return models.map((model, index) => counts.get(baseNames[index].toLowerCase()) === 1
|
||||
? baseNames[index]
|
||||
: stripClaudeAppGatewayOneMillionContextSuffix(model));
|
||||
const duplicateIndexes = new Map<string, number>();
|
||||
return models.map((_model, index) => {
|
||||
const baseName = baseNames[index];
|
||||
const key = baseName.toLowerCase();
|
||||
if (counts.get(key) === 1) {
|
||||
return baseName;
|
||||
}
|
||||
const duplicateIndex = (duplicateIndexes.get(key) ?? 0) + 1;
|
||||
duplicateIndexes.set(key, duplicateIndex);
|
||||
return `${baseName} #${duplicateIndex}`;
|
||||
});
|
||||
}
|
||||
|
||||
function claudeAppGatewayBaseDisplayName(model: string): string {
|
||||
|
|
@ -184,11 +322,24 @@ function claudeAppGatewayBaseDisplayName(model: string): string {
|
|||
return trimmed.includes("/") ? trimmed.slice(trimmed.lastIndexOf("/") + 1) : trimmed;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
function claudeAppGatewayDisplayNameWithProvider(model: string, displayName: string): string {
|
||||
const trimmed = model.trim();
|
||||
const separator = trimmed.indexOf("/");
|
||||
if (separator <= 0 || separator >= trimmed.length - 1) {
|
||||
return displayName;
|
||||
}
|
||||
const provider = trimmed.slice(0, separator).trim();
|
||||
if (!provider || displayName.toLowerCase().startsWith(`${provider.toLowerCase()}/`)) {
|
||||
return displayName;
|
||||
}
|
||||
return `${provider}/${displayName}`;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | undefined>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
const normalized = value?.trim() ?? "";
|
||||
const key = normalized.toLowerCase();
|
||||
if (!normalized || seen.has(key)) {
|
||||
continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue