diff --git a/examples/plugins/claude-design-plugin.cjs b/examples/plugins/claude-design-plugin.cjs index 89fd721..c97dc6b 100644 --- a/examples/plugins/claude-design-plugin.cjs +++ b/examples/plugins/claude-design-plugin.cjs @@ -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, diff --git a/examples/plugins/claude-design.config.example.jsonc b/examples/plugins/claude-design.config.example.jsonc index a22329b..cf6d739 100644 --- a/examples/plugins/claude-design.config.example.jsonc +++ b/examples/plugins/claude-design.config.example.jsonc @@ -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:/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. diff --git a/src/main/built-in-browser.ts b/src/main/built-in-browser.ts index 0fcb854..52b318e 100644 --- a/src/main/built-in-browser.ts +++ b/src/main/built-in-browser.ts @@ -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): 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(); diff --git a/src/main/claude-app-cdp.ts b/src/main/claude-app-cdp.ts new file mode 100644 index 0000000..ddf2760 --- /dev/null +++ b/src/main/claude-app-cdp.ts @@ -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; + +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 { + 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): Promise { + 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 { + 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 { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const targets = await cdpJson(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(port: number, endpoint: string): Promise { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class CdpClient { + private readonly handlers = new Map void>>(); + private nextId = 1; + private readonly pending = new Map 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 { + 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): Promise { + 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); +} diff --git a/src/main/claude-app-launch.ts b/src/main/claude-app-launch.ts index 960a3f2..7cb68a0 100644 --- a/src/main/claude-app-launch.ts +++ b/src/main/claude-app-launch.ts @@ -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 { 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", diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index dd399a6..250a984 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -140,7 +140,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType -
- {snapshot.status === "ok" ? null : {snapshot.status}} +
{meter ? {formatProviderAccountMeterValue(meter)} : null} - {sortedSnapshots.length > 1 ? {sortedSnapshots.length} {t("keys")} : null} + {sortedSnapshots.length > 1 ? {sortedSnapshots.length} {t("keys")} : null}
{providerAccountSnapshotCredentialLabel(snapshot) ? (
diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index a4e9b58..d06af17 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -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), diff --git a/src/shared/claude-app-gateway.ts b/src/shared/claude-app-gateway.ts index ba1b481..6d63575 100644 --- a/src/shared/claude-app-gateway.ts +++ b/src/shared/claude-app-gateway.ts @@ -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, 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[] {