Add Claude App design shell routing

This commit is contained in:
musistudio 2026-06-24 20:29:25 +08:00
parent 56688e83d9
commit e645bc5076
9 changed files with 558 additions and 50 deletions

View file

@ -31,6 +31,7 @@ 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 = [
@ -254,12 +255,15 @@ 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: "Open Claude Design through the CCR browser proxy.",
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: `https://${routeHost}/design`
url: browserAppUrl
});
const store = await ctx.openSqliteStore({
@ -390,6 +394,20 @@ 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",
@ -1075,6 +1093,27 @@ 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,6 +34,9 @@
"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

@ -575,16 +575,28 @@ function defaultBrowserAppsForPlugin(plugin: AppConfig["plugins"][number]): Gate
}
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: `https://${host}/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();

439
src/main/claude-app-cdp.ts Normal file
View file

@ -0,0 +1,439 @@
import { rmSync } from "node:fs";
import { createServer, type AddressInfo } from "node:net";
import path from "node:path";
import { WebSocket } from "undici";
type ClaudeAppCdpLogger = Pick<Console, "info" | "warn">;
type ClaudeAppDesignCdpOptions = {
cdpPort?: number;
designUrl?: string;
logger?: ClaudeAppCdpLogger;
};
type DevToolsTarget = {
id?: string;
title?: string;
type?: string;
url?: string;
webSocketDebuggerUrl?: string;
};
type CdpError = {
code?: number;
data?: unknown;
message?: string;
};
type CdpMessage = {
error?: CdpError;
id?: number;
method?: string;
params?: unknown;
result?: unknown;
};
type FetchRequestPausedParams = {
request?: {
url?: string;
};
requestId?: string;
};
const claudeAppDevToolsActivePortFile = "DevToolsActivePort";
const claudeAppDesignCdpConnectTimeoutMs = 15_000;
const claudeAppDesignCdpKeepAliveMs = 45_000;
const claudeAppDesignCdpPollIntervalMs = 250;
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console): Promise<number | undefined> {
const configured = Number(process.env.CCR_CLAUDE_APP_CDP_PORT);
if (Number.isInteger(configured) && configured > 0 && configured <= 65535) {
return configured;
}
try {
return await reserveLoopbackPort();
} catch (error) {
logger.warn(`[profile] Failed to reserve Claude App CDP port: ${nodeErrorMessage(error)}`);
return undefined;
}
}
export function prepareClaudeAppCdpUserDataDir(userDataDir: string): void {
rmSync(path.join(userDataDir, claudeAppDevToolsActivePortFile), { force: true });
}
export function scheduleClaudeAppDesignCdp(options: ClaudeAppDesignCdpOptions): void {
if (process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase() === "false") {
return;
}
const logger = options.logger || console;
const designUrl = normalizeClaudeAppDesignUrl(options.designUrl);
if (!options.cdpPort || !designUrl) {
return;
}
void forceOpenClaudeAppDesignViaCdp({
cdpPort: options.cdpPort,
designUrl,
logger
}).catch((error) => {
logger.warn(`[profile] Failed to force-open Claude Design via CDP: ${nodeErrorMessage(error)}`);
});
}
async function forceOpenClaudeAppDesignViaCdp(options: Required<ClaudeAppDesignCdpOptions>): 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}.`);
}
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
try {
client.on("Fetch.requestPaused", (params) => {
void handleFetchRequestPaused(client, params as FetchRequestPausedParams, options.logger);
});
await client.send("Page.enable");
await client.send("Runtime.enable");
await client.send("Fetch.enable", {
patterns: [
{
requestStage: "Request",
urlPattern: "app://localhost/v1/privacy-consents*"
}
]
});
await client.send("Page.setBypassCSP", { enabled: true });
await client.send("Page.addScriptToEvaluateOnNewDocument", {
source: claudeAppDesignFeatureScript()
});
await client.send("Runtime.evaluate", {
awaitPromise: false,
expression: claudeAppDesignFeatureScript()
});
await client.send("Page.navigate", {
url: claudeAppDesktopDesignUrl(options.designUrl)
});
await sleep(1_200);
await client.send("Runtime.evaluate", {
awaitPromise: false,
expression: claudeAppDesignFrameScript(options.designUrl)
});
options.logger.info(`[profile] Force-opened Claude Design via CDP at ${options.designUrl}.`);
await sleep(claudeAppDesignCdpKeepAliveMs);
} finally {
client.close();
}
}
async function handleFetchRequestPaused(client: CdpClient, params: FetchRequestPausedParams, logger: ClaudeAppCdpLogger): Promise<void> {
const requestId = params.requestId;
if (!requestId) {
return;
}
const url = params.request?.url || "";
try {
if (url.startsWith("app://localhost/v1/privacy-consents")) {
await client.send("Fetch.fulfillRequest", {
body: Buffer.from(JSON.stringify({
consents: {},
ok: true,
values: {}
})).toString("base64"),
responseCode: 200,
responseHeaders: [
{ name: "content-type", value: "application/json; charset=utf-8" },
{ name: "cache-control", value: "no-store" }
],
requestId
});
return;
}
await client.send("Fetch.continueRequest", { requestId });
} catch (error) {
logger.warn(`[profile] Failed to handle Claude App CDP request ${url}: ${nodeErrorMessage(error)}`);
}
}
async function waitForClaudeAppPageTarget(port: number, timeoutMs: number): Promise<DevToolsTarget> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const targets = await cdpJson<DevToolsTarget[]>(port, "/json/list");
const target = targets.find(isClaudeAppPageTarget) ||
targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl);
if (target) {
return target;
}
} catch (error) {
lastError = error;
}
await sleep(claudeAppDesignCdpPollIntervalMs);
}
throw new Error(`Claude App CDP page target was not available on port ${port}${lastError ? `: ${nodeErrorMessage(lastError)}` : ""}`);
}
function isClaudeAppPageTarget(target: DevToolsTarget): boolean {
if (target.type !== "page" || !target.webSocketDebuggerUrl) {
return false;
}
const url = target.url || "";
return url.startsWith("app://localhost/") || url.startsWith("app://-/") || /claude/i.test(target.title || "");
}
async function cdpJson<T>(port: number, endpoint: string): Promise<T> {
const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, {
signal: AbortSignal.timeout(1_000)
});
if (!response.ok) {
throw new Error(`CDP ${endpoint} returned HTTP ${response.status}`);
}
return await response.json() as T;
}
function claudeAppDesktopDesignUrl(designUrl: string): string {
return `app://localhost/desktop-design?path=${encodeURIComponent(designUrl)}`;
}
function claudeAppDesignFeatureScript(): string {
return `(() => {
const forced = { claudeDesignWindow: { status: "supported" } };
function merge(value) {
return Object.assign({}, value || {}, forced);
}
let bootFeatures = merge(globalThis.desktopBootFeatures);
try {
Object.defineProperty(globalThis, "desktopBootFeatures", {
configurable: true,
get() {
return bootFeatures;
},
set(value) {
bootFeatures = merge(value);
}
});
} catch (_) {
globalThis.desktopBootFeatures = bootFeatures;
}
globalThis.claude = globalThis.claude || {};
globalThis.claude.settings = globalThis.claude.settings || {};
const existing = globalThis.claude.settings.AppFeatures || {};
const previous = existing.getSupportedFeatures;
globalThis.claude.settings.AppFeatures = Object.assign({}, existing, {
__ccrClaudeDesignPatched: true,
getSupportedFeatures() {
if (typeof previous === "function") {
return Promise.resolve(previous.call(existing)).then(merge, () => merge());
}
return Promise.resolve(merge());
}
});
})();`;
}
function claudeAppDesignFrameScript(designUrl: string): string {
const target = JSON.stringify(designUrl);
return `(() => {
const target = ${target};
function forceFrame() {
const frames = Array.from(document.querySelectorAll("iframe"));
const designFrame = frames.find((frame) => /design|desktop-design|omelette/i.test(frame.getAttribute("src") || frame.id || frame.className || ""));
const frame = designFrame || frames[0];
if (frame && frame.src !== target) {
frame.src = target;
}
}
forceFrame();
globalThis.__ccrClaudeDesignFrameTimer = globalThis.__ccrClaudeDesignFrameTimer || setInterval(forceFrame, 500);
})();`;
}
function normalizeClaudeAppDesignUrl(value: string | undefined): string {
const raw = value?.trim();
if (!raw) {
return "";
}
try {
const url = new URL(raw);
if (!isLocalHttpUrl(url)) {
return "";
}
url.pathname = normalizeDesignPath(url.pathname);
url.searchParams.set("__ccr_design_iframe", "1");
return url.toString();
} catch {
return "";
}
}
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";
}
return value.startsWith("/") ? value : `/${value}`;
}
function reserveLoopbackPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen({ host: "127.0.0.1", port: 0 }, () => {
const address = server.address() as AddressInfo | null;
const port = address?.port;
server.close((error) => {
if (error) {
reject(error);
return;
}
if (!port) {
reject(new Error("No loopback port was assigned."));
return;
}
resolve(port);
});
});
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class CdpClient {
private readonly handlers = new Map<string, Array<(params: unknown) => void>>();
private nextId = 1;
private readonly pending = new Map<number, {
reject: (error: Error) => void;
resolve: (value: unknown) => void;
}>();
private constructor(private readonly ws: WebSocket) {
ws.addEventListener("message", (event) => this.handleMessage(event.data));
ws.addEventListener("close", () => this.rejectPending(new Error("CDP WebSocket closed.")));
ws.addEventListener("error", () => this.rejectPending(new Error("CDP WebSocket failed.")));
}
static connect(url: string): Promise<CdpClient> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
try {
ws.close();
} catch {
// Ignore close failures during timeout cleanup.
}
reject(new Error("Timed out connecting to Claude App CDP WebSocket."));
}, 5_000);
ws.addEventListener("open", () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve(new CdpClient(ws));
});
ws.addEventListener("error", () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
reject(new Error("Failed to connect to Claude App CDP WebSocket."));
});
});
}
close(): void {
if (this.ws.readyState === 0 || this.ws.readyState === 1) {
this.ws.close();
}
}
on(method: string, handler: (params: unknown) => void): void {
const handlers = this.handlers.get(method) || [];
handlers.push(handler);
this.handlers.set(method, handlers);
}
send(method: string, params?: Record<string, unknown>): Promise<unknown> {
if (this.ws.readyState !== 1) {
return Promise.reject(new Error("CDP WebSocket is not open."));
}
const id = this.nextId++;
const payload = params === undefined ? { id, method } : { id, method, params };
this.ws.send(JSON.stringify(payload));
return new Promise((resolve, reject) => {
this.pending.set(id, { reject, resolve });
});
}
private handleMessage(data: unknown): void {
let message: CdpMessage;
try {
message = JSON.parse(webSocketDataToString(data)) as CdpMessage;
} catch {
return;
}
if (typeof message.id === "number") {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || `CDP command failed with code ${message.error.code || "unknown"}`));
return;
}
pending.resolve(message.result);
return;
}
if (message.method) {
for (const handler of this.handlers.get(message.method) || []) {
handler(message.params);
}
}
}
private rejectPending(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
}
function webSocketDataToString(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
}
return String(data);
}
function nodeErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileConfig } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "./claude-app-cdp";
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery";
@ -15,6 +16,7 @@ type ClaudeAppLookupResult = {
export type ClaudeAppLaunchResult = {
child: ChildProcess;
command: string;
cdpPort?: number;
pid?: number;
userDataDir: string;
};
@ -31,7 +33,7 @@ const windowsClaudeExeNames = [
];
const windowsClaudePackageKeywords = ["claude", "anthropic"];
export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): ClaudeAppLaunchResult {
export async function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): Promise<ClaudeAppLaunchResult> {
const lookup = findInstalledClaudeAppExecutable();
if (!lookup.executable) {
throw new Error([
@ -44,6 +46,8 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig
const settingsDir = path.dirname(settingsFile);
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
mkdirSync(userDataDir, { recursive: true });
prepareClaudeAppCdpUserDataDir(userDataDir);
const cdpPort = await reserveClaudeAppCdpPort(console);
const env: NodeJS.ProcessEnv = {
...process.env,
@ -57,16 +61,23 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig
};
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir), {
const designUrl = claudeAppDesignUrl(config);
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort), {
detached: true,
env,
stdio: "ignore"
});
child.unref();
scheduleClaudeAppDesignCdp({
cdpPort,
designUrl,
logger: console
});
return {
child,
command: lookup.executable,
...(cdpPort ? { cdpPort } : {}),
pid: child.pid,
userDataDir
};
@ -77,8 +88,41 @@ export function resolveClaudeAppProfileUserDataDir(configDir: string, profile: P
return claudeElectronUserDataDir(path.dirname(settingsFile), profile);
}
function claudeElectronArgs(userDataDir: string): string[] {
function claudeAppDesignUrl(config: AppConfig | undefined): string | undefined {
if (!config) {
return undefined;
}
const port = Number.isInteger(config.gateway?.port) && config.gateway.port > 0
? config.gateway.port
: Number.isInteger(config.PORT) && config.PORT > 0
? config.PORT
: undefined;
if (!port) {
return undefined;
}
const host = formatLoopbackGatewayHost(config.gateway?.host || "127.0.0.1");
return `http://${host}:${port}/design`;
}
function formatLoopbackGatewayHost(host: string): string {
const trimmed = host.trim();
if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") {
return "127.0.0.1";
}
if (trimmed.includes(":") && !trimmed.startsWith("[")) {
return `[${trimmed}]`;
}
return trimmed;
}
function claudeElectronArgs(userDataDir: string, cdpPort?: number): string[] {
return [
...(cdpPort
? [
`--remote-debugging-port=${cdpPort}`,
"--remote-debugging-address=127.0.0.1"
]
: []),
`--user-data-dir=${userDataDir}`,
"--remote-allow-origins=*",
"--disable-renderer-backgrounding",

View file

@ -140,7 +140,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile)
});
await ensureGatewayConfigRunning(profileGatewayConfig, "Claude App");
activateProfileAppWindow(registerProfileApp(profile, "app", launchClaudeAppProfile(CONFIGDIR, profile, config)));
activateProfileAppWindow(registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig)));
startClaudeAppBotWorker(config, profile);
return {
message: `Opened Claude App with ${profile.name || profile.id}.`,

View file

@ -7,7 +7,7 @@ import {
DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig,
GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label,
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountBadgeVariant,
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
@ -340,10 +340,9 @@ function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayPro
return (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
{snapshot.status === "ok" ? null : <Badge variant={providerAccountBadgeVariant(snapshot.status)}>{snapshot.status}</Badge>}
<div className="flex min-w-0 items-center gap-1.5 text-[11px]">
{meter ? <span className="min-w-0 truncate text-[11px] font-medium">{formatProviderAccountMeterValue(meter)}</span> : null}
{sortedSnapshots.length > 1 ? <Badge variant="outline">{sortedSnapshots.length} {t("keys")}</Badge> : null}
{sortedSnapshots.length > 1 ? <span className="shrink-0 text-muted-foreground">{sortedSnapshots.length} {t("keys")}</span> : null}
</div>
{providerAccountSnapshotCredentialLabel(snapshot) ? (
<div className="mt-0.5 truncate text-[10px] font-semibold text-muted-foreground" title={providerAccountSnapshotLabel(snapshot)}>

View file

@ -3197,19 +3197,19 @@ function prepareClaudeAppFallbackModelRequest(
const parsedBody = parseJsonObjectSafe(body);
const model = stringValue(parsedBody?.model);
const normalizedModel = normalizeRouteSelector(model);
if (
!parsedBody ||
!normalizedModel ||
isConfiguredGatewayModelSelector(normalizedModel, config)
) {
if (!parsedBody || !normalizedModel) {
return undefined;
}
const routedModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions) ??
const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions);
const routedModel = routeModel ??
(normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined);
if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) {
return undefined;
}
if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) {
return undefined;
}
return {
body: serializeJsonBodyWithModel(parsedBody, routedModel),

View file

@ -3,8 +3,6 @@ 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_CCR_ROUTE_PREFIX = "claude-ccr-";
const CLAUDE_APP_CCR_ROUTE_SLUG_MAX_LENGTH = 72;
const CLAUDE_APP_ROUTE_NAMES = [
"claude-sonnet-4-5",
@ -42,15 +40,15 @@ export function buildClaudeAppGatewayModelRoutes(
config: Pick<AppConfig, "Providers" | "Router" | "profile" | "virtualModelProfiles">,
options: ClaudeAppGatewayModelRouteOptions = {}
): ClaudeAppGatewayModelRoute[] {
const targetModels = claudeAppGatewayTargetModels(config);
const targetModels = claudeAppGatewayTargetModels(config).slice(0, CLAUDE_APP_ROUTE_NAMES.length);
const displayNames = claudeAppGatewayDisplayNames(targetModels);
return targetModels.map((rawTargetModel, index) => {
const targetModel = stripClaudeAppGatewayOneMillionContextSuffix(rawTargetModel);
const oneMillionContext = claudeAppGatewaySupportsOneMillionContext(rawTargetModel, options);
const routeId = claudeAppGatewayRouteId(targetModel, index);
const routeId = claudeAppGatewayRouteId(index);
return {
displayName: oneMillionContext ? `${displayNames[index]} (1M context)` : displayNames[index],
id: oneMillionContext ? claudeAppGatewayOneMillionContextModelId(routeId) : routeId,
id: routeId,
oneMillionContext,
targetModel
};
@ -155,34 +153,8 @@ function claudeAppGatewaySupportsOneMillionContext(
Boolean(options.supportsOneMillionContext?.(baseModel));
}
function claudeAppGatewayRouteId(targetModel: string, index: number): string {
return CLAUDE_APP_ROUTE_NAMES[index] ??
`${CLAUDE_APP_CCR_ROUTE_PREFIX}${claudeAppGatewayRouteSlug(targetModel)}-${claudeAppGatewayRouteHash(targetModel)}`;
}
function claudeAppGatewayRouteSlug(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, CLAUDE_APP_CCR_ROUTE_SLUG_MAX_LENGTH)
.replace(/-+$/g, "");
return slug || "model";
}
function claudeAppGatewayRouteHash(value: string): string {
let hash = 0x811c9dc5;
for (const char of value.trim().toLowerCase()) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36).padStart(6, "0").slice(0, 6);
}
function claudeAppGatewayOneMillionContextModelId(id: string): string {
return hasClaudeAppGatewayOneMillionContextSuffix(id) ? id : `${id}${CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX}`;
function claudeAppGatewayRouteId(index: number): string {
return CLAUDE_APP_ROUTE_NAMES[index] ?? CLAUDE_APP_FALLBACK_MODEL;
}
function claudeAppGatewayDisplayNames(models: string[]): string[] {