Decouple Claude Design proxy from browser app

This commit is contained in:
musistudio 2026-06-26 22:48:53 +08:00
parent 27ffe73c6d
commit 31e0756ae4
10 changed files with 351 additions and 186 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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();

View file

@ -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";

View file

@ -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) {

View file

@ -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, "");
}

View file

@ -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"

View file

@ -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,

View file

@ -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);
}

View file

@ -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;