From 98cd66f261dc55c877f7ecaed653218a9e9715ab Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 30 Jun 2026 20:02:35 +0800 Subject: [PATCH] Simplify routing config and improve network logs --- README.md | 8 +- src/main/config.ts | 44 +- .../pages/home/components/extensions.tsx | 12 - .../pages/home/components/network-logs.tsx | 451 +++++++++++++++--- src/renderer/pages/home/shared/extensions.ts | 4 +- src/renderer/pages/home/shared/i18n.tsx | 8 + src/renderer/pages/home/shared/options.ts | 13 +- src/renderer/pages/home/shared/providers.ts | 35 -- src/renderer/pages/home/shared/routing.ts | 81 +--- src/renderer/pages/home/shared/types.ts | 2 +- src/renderer/styles/globals.css | 47 ++ .../gateway/claude-code-router-plugin.ts | 60 +-- src/server/gateway/service.ts | 96 +++- src/shared/app.ts | 7 +- tests/main/gateway-virtual-models.test.mjs | 61 +++ 15 files changed, 633 insertions(+), 296 deletions(-) create mode 100644 tests/main/gateway-virtual-models.test.mjs diff --git a/README.md b/README.md index 255c5565..d41af53d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ CCR runs on your machine, keeps provider configuration in your local config dire ## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. -- Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models. +- Route requests with explicit rules instead of editing client configuration by hand. - Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. - Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. - Manage everything from a desktop UI instead of editing JSON by hand. @@ -28,7 +28,7 @@ CCR runs on your machine, keeps provider configuration in your local config dire - **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings. - **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available. -- **Routing rules**: set default, background, thinking, long-context, image, web-search, subagent, model-prefix, and conditional routing rules. +- **Routing rules**: configure conditional and model-prefix routing rules with fallback handling. - **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway. - **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime. - **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. @@ -88,9 +88,9 @@ Open **Providers**, click **Add Provider**, then choose a built-in preset or cre ### 2. Configure routing -Open **Routing** and select which provider/model should handle the default route. Then fill optional routes for background work, thinking requests, long-context requests, image tasks, and web search if you want different models for those scenarios. +Open **Routing** to add explicit rules and configure failure handling. -Use **Add Routing Rule** when you need more control, such as model-prefix routing, subagent routing, request conditions, or fallback behavior. +Use **Add Routing Rule** for request conditions, model-prefix routing, or fallback behavior. ### 3. Start the gateway diff --git a/src/main/config.ts b/src/main/config.ts index 03fd28ab..2e61d525 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1171,7 +1171,7 @@ function parseRouter(value: unknown): Partial | undefined { } const router: Partial = {}; - for (const key of ["background", "default", "image", "longContext", "think", "webSearch"] as const) { + for (const key of ["background", "default"] as const) { const route = readString(value[key]); if (route) { router[key] = route; @@ -1296,8 +1296,7 @@ function parseRouterRules(value: unknown): RouterRule[] | undefined { const pattern = readString(item.pattern); const threshold = readNumber(item.threshold); const condition = parseRouterRuleCondition(item.condition ?? item) ?? routerRuleConditionFromLegacy(type, { - pattern, - threshold: threshold !== undefined && threshold > 0 ? threshold : undefined + pattern }); const rewrites = parseRouterRuleRewrites(item); const fallback = parseRouterFallback(item.fallback ?? item.failureFallback ?? item.fallbackStrategy); @@ -1313,7 +1312,7 @@ function parseRouterRules(value: unknown): RouterRule[] | undefined { ...(rewrites.length > 0 ? { rewrites } : {}), ...(target ? { target } : {}), ...(threshold !== undefined && threshold > 0 ? { threshold } : {}), - type: condition && type !== "subagent" ? "condition" : type + type: condition ? "condition" : type }; }) .filter((item): item is RouterRule => Boolean(item)); @@ -1327,12 +1326,7 @@ function parseRouterRuleType(value: unknown): RouterRuleType | undefined { const normalized = value.trim().toLowerCase(); if ( normalized === "condition" || - normalized === "image" || - normalized === "long-context" || - normalized === "model-prefix" || - normalized === "subagent" || - normalized === "thinking" || - normalized === "web-search" + normalized === "model-prefix" ) { return normalized; } @@ -1381,15 +1375,8 @@ function parseRouterRuleOperator(value: unknown): RouterRuleOperator | undefined function routerRuleConditionFromLegacy( type: RouterRuleType, - input: { pattern?: string; threshold?: number } + input: { pattern?: string } ): RouterRuleCondition | undefined { - if (type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(input.threshold ?? "200000") - }; - } if (type === "model-prefix" && input.pattern) { return { left: "request.body.model", @@ -1397,27 +1384,6 @@ function routerRuleConditionFromLegacy( right: input.pattern }; } - if (type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } return undefined; } diff --git a/src/renderer/pages/home/components/extensions.tsx b/src/renderer/pages/home/components/extensions.tsx index e07818fb..0535a162 100644 --- a/src/renderer/pages/home/components/extensions.tsx +++ b/src/renderer/pages/home/components/extensions.tsx @@ -303,13 +303,6 @@ export function ConfigureClaudeDesignDialog({ onChange({ enabled })} /> - - onChange({ defaultTarget })} - value={draft.defaultTarget} - /> -
@@ -350,11 +343,6 @@ export function ConfigureClaudeDesignDialog({ onChangeRule(index, { pattern: event.target.value })} /> ) : null} - {rule.type === "long-context" ? ( - - onChangeRule(index, { threshold: event.target.value })} /> - - ) : null} {isClaudeDesignStaticRuleType(rule.type) ? (
{t(claudeDesignRouteRuleTypeLabel(rule.type))}
) : null} diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index 48117dc7..c92aa020 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { Maximize2, X } from "lucide-react"; import { AnimatedIconSwap, Check, ChevronDown, ChevronLeft, ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard, @@ -21,6 +22,26 @@ const logJsonAutoExpandEntryLimit = 60; const logJsonContainerPreviewLimit = 80; const logJsonAutoExpandTextLimit = 160 * 1024; const logBodyViewCache = new Map>(); +type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration"; +type LogTableColumn = { + id: LogTableColumnId; + minWidth: number; +}; +type LogTableColumnWidths = Partial>; +type LogTableGridStyle = { + gridTemplateColumns: string; + minWidth: string; +}; + +const baseLogTableColumns: LogTableColumn[] = [ + { id: "time", minWidth: 150 }, + { id: "status", minWidth: 116 }, + { id: "stream", minWidth: 108 }, + { id: "model", minWidth: 180 }, + { id: "tokens", minWidth: 140 }, + { id: "duration", minWidth: 92 } +]; +const credentialLogTableColumn: LogTableColumn = { id: "credential", minWidth: 128 }; export function NetworkingView({ clearCaptures, @@ -287,14 +308,21 @@ export function LogsView({ const [detailById, setDetailById] = useState>({}); const [detailErrorById, setDetailErrorById] = useState>({}); const [detailLoadingId, setDetailLoadingId] = useState(); + const [logColumnWidths, setLogColumnWidths] = useState({}); + const logTableHeaderRef = useRef(null); const firstItem = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1; const lastItem = Math.min(page.total, page.page * page.pageSize); const hasAnyCredentialInfo = Boolean(filter.credential) || page.options.credentials.length > 0 || page.items.some(logHasCredentialInfo); + const visibleLogColumns = useMemo(() => getLogTableColumns(hasAnyCredentialInfo), [hasAnyCredentialInfo]); const logTableGridClass = hasAnyCredentialInfo ? "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.78fr)_minmax(120px,0.42fr)_minmax(0,0.68fr)_82px]" : "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.9fr)_minmax(0,0.74fr)_82px]"; + const logTableGridStyle = useMemo( + () => createLogTableGridStyle(visibleLogColumns, logColumnWidths), + [logColumnWidths, visibleLogColumns] + ); const loadLogDetail = useCallback((id: number) => { if (detailById[id] || detailLoadingId === id || !window.ccr?.getRequestLogDetail) { return; @@ -333,6 +361,55 @@ export function LogsView({ setExpandedId(undefined); }, [expandedId, page.items]); + function startLogColumnResize(columnIndex: number, event: ReactPointerEvent) { + const header = logTableHeaderRef.current; + const leftColumn = visibleLogColumns[columnIndex]; + const rightColumn = visibleLogColumns[columnIndex + 1]; + if (!header || !leftColumn || !rightColumn) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const measuredWidths: LogTableColumnWidths = {}; + visibleLogColumns.forEach((column, index) => { + const width = header.children[index]?.getBoundingClientRect().width ?? column.minWidth; + measuredWidths[column.id] = Math.round(clampNumber(width, column.minWidth, Number.MAX_SAFE_INTEGER)); + }); + + const startX = event.clientX; + const startLeftWidth = measuredWidths[leftColumn.id] ?? leftColumn.minWidth; + const startRightWidth = measuredWidths[rightColumn.id] ?? rightColumn.minWidth; + const minDelta = leftColumn.minWidth - startLeftWidth; + const maxDelta = startRightWidth - rightColumn.minWidth; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const update = (pointerEvent: PointerEvent) => { + const delta = clampNumber(pointerEvent.clientX - startX, minDelta, maxDelta); + setLogColumnWidths((current) => ({ + ...current, + ...measuredWidths, + [leftColumn.id]: Math.round(startLeftWidth + delta), + [rightColumn.id]: Math.round(startRightWidth - delta) + })); + }; + const stop = () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", update); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + + window.addEventListener("pointermove", update); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + } + return (
-
- - - - - {hasAnyCredentialInfo ? : null} - - +
+ {visibleLogColumns.map((column, index) => ( + startLogColumnResize(index, event) : undefined} + resizeLabel={t("Resize column width")} + /> + ))}
{page.items.length === 0 ? ( @@ -458,6 +540,7 @@ export function LogsView({ item={expandedId === item.id ? detailById[item.id] ?? item : item} key={item.id} logTableGridClass={logTableGridClass} + logTableGridStyle={logTableGridStyle} onToggle={toggleExpandedLog} /> ))} @@ -469,6 +552,51 @@ export function LogsView({ ); } +function getLogTableColumns(hasCredentialColumn: boolean): LogTableColumn[] { + if (!hasCredentialColumn) { + return baseLogTableColumns; + } + return [ + ...baseLogTableColumns.slice(0, 4), + credentialLogTableColumn, + ...baseLogTableColumns.slice(4) + ]; +} + +function createLogTableGridStyle(columns: LogTableColumn[], widths: LogTableColumnWidths): LogTableGridStyle | undefined { + const columnWidths = columns.map((column) => widths[column.id]); + if (columnWidths.some((width) => typeof width !== "number")) { + return undefined; + } + + return { + gridTemplateColumns: columns.map((column, index) => { + const width = Math.max(column.minWidth, Math.round(columnWidths[index] ?? column.minWidth)); + return `minmax(${column.minWidth}px, ${width}fr)`; + }).join(" "), + minWidth: `${columns.reduce((total, column) => total + column.minWidth, 0)}px` + }; +} + +function logTableColumnLabel(columnId: LogTableColumnId, t: (value: string) => string): string { + switch (columnId) { + case "time": + return t("时间"); + case "status": + return t("状态"); + case "stream": + return t("Stream"); + case "model": + return t("模型"); + case "credential": + return t("Credential"); + case "tokens": + return t("令牌"); + case "duration": + return t("持续时间"); + } +} + const LogRow = memo(function LogRow({ detailError, detailLoading, @@ -477,6 +605,7 @@ const LogRow = memo(function LogRow({ index, item, logTableGridClass, + logTableGridStyle, onToggle }: { detailError?: string; @@ -486,6 +615,7 @@ const LogRow = memo(function LogRow({ index: number; item: RequestLogEntry; logTableGridClass: string; + logTableGridStyle?: LogTableGridStyle; onToggle: (id: number) => void; }) { const t = useAppText(); @@ -504,6 +634,7 @@ const LogRow = memo(function LogRow({ expanded && "network-row-selected" )} onClick={() => onToggle(item.id)} + style={logTableGridStyle} type="button" >
@@ -707,6 +838,7 @@ function LogJsonPanel({ const t = useAppText(); const [selectedTab, setSelectedTab] = useState("body"); const [preferTextBody, setPreferTextBody] = useState(false); + const [fullscreenOpen, setFullscreenOpen] = useState(false); const [query, setQuery] = useState(""); const bodyKey = logBodyCacheKey(body); const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]); @@ -721,6 +853,19 @@ function LogJsonPanel({ setPreferTextBody(false); }, [bodyKey]); + useEffect(() => { + if (!fullscreenOpen) { + return; + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setFullscreenOpen(false); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [fullscreenOpen]); + function toggleJsonPath(path: string) { setExpandedJsonPaths((current) => { const next = new Set(current); @@ -757,36 +902,49 @@ function LogJsonPanel({
{selectedTab === "body" ? ( <> -
-
- - setQuery(event.target.value)} - placeholder={t("筛选 JSON...")} - value={query} - /> -
- {bodyView.json !== undefined && query.trim() === "" ? ( - - ) : null} - {body?.contentType ? {body.contentType} : null} - {body?.truncated ? {t("truncated")} : null} -
- - {showJsonTree ? ( - - ) : ( -
{visible}
- )} + setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + title={title} + /> + setFullscreenOpen(true)} + > + + {fullscreenOpen ? ( + setFullscreenOpen(false)} + onQueryChange={setQuery} + onToggleJsonPath={toggleJsonPath} + onToggleTextBody={() => setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + showJsonTree={showJsonTree} + subtitle={subtitle} + title={title} + visible={visible} + value={bodyView.json} + /> + ) : null} ) : (
@@ -798,6 +956,153 @@ function LogJsonPanel({ ); } +function LogJsonBodyToolbar({ + body, + bodyView, + onQueryChange, + onToggleTextBody, + preferTextBody, + query, + title +}: { + body?: RequestLogBody; + bodyView: ReturnType; + onQueryChange: (value: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + title: string; +}) { + const t = useAppText(); + + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={t("筛选 JSON...")} + value={query} + /> +
+ {bodyView.json !== undefined && query.trim() === "" ? ( + + ) : null} + {body?.contentType ? {body.contentType} : null} + {body?.truncated ? {t("truncated")} : null} +
+ ); +} + +function LogJsonBodyContent({ + expandedJsonPaths, + onToggleJsonPath, + showJsonTree, + value, + visible +}: { + expandedJsonPaths: Set; + onToggleJsonPath: (path: string) => void; + showJsonTree: boolean; + value: unknown; + visible: string; +}) { + return showJsonTree ? ( + + ) : ( +
{visible}
+ ); +} + +function LogJsonFullscreenViewer({ + body, + bodyView, + copyLabel, + copyText, + expandedJsonPaths, + onClose, + onQueryChange, + onToggleJsonPath, + onToggleTextBody, + preferTextBody, + query, + showJsonTree, + subtitle, + title, + value, + visible +}: { + body?: RequestLogBody; + bodyView: ReturnType; + copyLabel: string; + copyText: string; + expandedJsonPaths: Set; + onClose: () => void; + onQueryChange: (value: string) => void; + onToggleJsonPath: (path: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + showJsonTree: boolean; + subtitle?: string; + title: string; + value: unknown; + visible: string; +}) { + const t = useAppText(); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} + +
+ +
+ + + +
+
+ ); +} + function logBodyCacheKey(body: RequestLogBody | undefined): string { if (!body) { return "missing"; @@ -901,11 +1206,15 @@ function jsonContainerHiddenSummary(value: Record | unknown[], function LogBodyViewer({ children, copyLabel, - copyText + copyText, + fullscreenLabel, + onFullscreen }: { children: ReactNode; copyLabel: string; copyText: string; + fullscreenLabel?: string; + onFullscreen?: () => void; }) { const t = useAppText(); const [copied, setCopied] = useState(false); @@ -925,20 +1234,33 @@ function LogBodyViewer({ return (
- +
+ {onFullscreen ? ( + + ) : null} + +
{children}
); @@ -954,7 +1276,7 @@ function LogJsonTree({ value: unknown; }) { return ( -
+
); @@ -1091,10 +1413,27 @@ function JsonPrimitiveValue({ value }: { value: unknown }) { return {String(value)}; } -function NetworkHeaderCell({ label }: { label: string }) { +function NetworkHeaderCell({ + label, + onResizeStart, + resizeLabel +}: { + label: string; + onResizeStart?: (event: ReactPointerEvent) => void; + resizeLabel?: string; +}) { return ( -
- {label} +
+ {label} + {onResizeStart ? ( +
); } diff --git a/src/renderer/pages/home/shared/extensions.ts b/src/renderer/pages/home/shared/extensions.ts index 865da24f..0c51be2e 100644 --- a/src/renderer/pages/home/shared/extensions.ts +++ b/src/renderer/pages/home/shared/extensions.ts @@ -697,12 +697,12 @@ export function wrapperPluginCapability(item: Record): string { if (isClaudeDesignPluginConfig(item)) { const routing = readClaudeDesignRoutingConfig(item.config); - const routeCount = routing.rules.length + (routing.defaultTarget ? 1 : 0); + const routeCount = routing.rules.length; capabilities.push(routeCount > 0 ? `${routeCount} model ${routeCount === 1 ? "route" : "routes"}` : "Configurable routing"); } if (isCursorProxyPluginConfig(item)) { const routing = readClaudeDesignRoutingConfig(item.config); - const routeCount = routing.rules.length + (routing.defaultTarget ? 1 : 0); + const routeCount = routing.rules.length; capabilities.push(routeCount > 0 ? `${routeCount} model ${routeCount === 1 ? "route" : "routes"}` : "Configurable routing"); } diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index a0ba92e0..66375432 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -320,6 +320,10 @@ export const appCopy: Record = { "Requests, tokens, cost": "Requests, tokens, cost", "Remove widget": "Remove widget", "Reset layout": "Reset layout", + "Close fullscreen JSON viewer": "Close fullscreen JSON viewer", + "Fullscreen JSON viewer": "Fullscreen JSON viewer", + "Open fullscreen JSON viewer": "Open fullscreen JSON viewer", + "Resize column width": "Resize column width", "Resize widget height": "Resize widget height", "Resize widget size": "Resize widget size", "Resize widget width": "Resize widget width", @@ -1033,6 +1037,10 @@ export const appCopy: Record = { "Requests, tokens, cost": "请求、Token、成本", "Result": "结果", "Reset layout": "重置布局", + "Close fullscreen JSON viewer": "关闭全屏 JSON 查看器", + "Fullscreen JSON viewer": "全屏 JSON 查看器", + "Open fullscreen JSON viewer": "全屏查看 JSON", + "Resize column width": "调整列宽", "Resize widget height": "调整组件高度", "Resize widget size": "调整组件大小", "Resize widget width": "调整组件宽度", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index e0578e26..e10caa39 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -65,7 +65,7 @@ type ProviderAccountDraftMode = "standard" | "http-json" | "raw"; type ApiKeyLimitMetric = "images" | "requests" | "tokens"; type ApiKeyExpirationPreset = "7d" | "30d" | "90d" | "custom" | "never"; type LimitWindowPreset = "day" | "hour" | "minute"; -type ClaudeDesignRouteRuleType = "always" | "image" | "long-context" | "model" | "model-prefix" | "thinking" | "web-search"; +type ClaudeDesignRouteRuleType = "always" | "model" | "model-prefix"; type VirtualModelClientToolsPolicy = "allow" | "deny"; type VirtualModelMatchMode = "alias" | "prefix" | "suffix"; export type AgentFilterValue = AgentKind | "all"; @@ -211,12 +211,7 @@ export const routerRewriteOperationOptions: Array<{ label: string; value: Router ]; export const legacyRouterRuleTypes: RouterRuleType[] = [ - "image", - "long-context", - "model-prefix", - "subagent", - "thinking", - "web-search" + "model-prefix" ]; export const routerFallbackModeOptions: Array<{ label: string; value: RouterFallbackMode }> = [ @@ -236,10 +231,6 @@ export const removedLegacyRouterRuleIds = new Set([ export const claudeDesignRouteRuleTypeOptions: Array<{ label: string; value: ClaudeDesignRouteRuleType }> = [ { label: "Exact model", value: "model" }, { label: "Model prefix", value: "model-prefix" }, - { label: "Long context", value: "long-context" }, - { label: "Thinking", value: "thinking" }, - { label: "Web search", value: "web-search" }, - { label: "Image content", value: "image" }, { label: "Always", value: "always" } ]; diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index 71864da2..2e434f73 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -506,13 +506,6 @@ export function routerRuleConditionFromRule(rule: RouterRule, config?: AppConfig if (rule.type === "condition") { return undefined; } - if (rule.type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(rule.threshold ?? config?.Router.longContextThreshold ?? "200000") - }; - } if (rule.type === "model-prefix") { return { left: "request.body.model", @@ -520,34 +513,6 @@ export function routerRuleConditionFromRule(rule: RouterRule, config?: AppConfig right: rule.pattern ?? "" }; } - if (rule.type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (rule.type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (rule.type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } - if (rule.type === "subagent") { - return { - left: "request.body.system.1.text", - operator: "==", - right: "" - }; - } return undefined; } diff --git a/src/renderer/pages/home/shared/routing.ts b/src/renderer/pages/home/shared/routing.ts index c9853631..ce8c9092 100644 --- a/src/renderer/pages/home/shared/routing.ts +++ b/src/renderer/pages/home/shared/routing.ts @@ -442,8 +442,7 @@ export function normalizeRouterRules(value: unknown): RouterRule[] | undefined { const target = stringValue(item.target); const threshold = Number(item.threshold); const condition = normalizeRouterRuleCondition(item.condition ?? item) ?? routerRuleConditionFromLegacy(type, { - pattern, - threshold: Number.isFinite(threshold) && threshold > 0 ? Math.trunc(threshold) : undefined + pattern }); const rewrites = normalizeRouterRuleRewrites(item); const rawFallback = item.fallback ?? item.failureFallback ?? item.fallbackStrategy; @@ -459,7 +458,7 @@ export function normalizeRouterRules(value: unknown): RouterRule[] | undefined { ...(rewrites.length > 0 ? { rewrites } : {}), ...(target ? { target } : {}), ...(Number.isFinite(threshold) && threshold > 0 ? { threshold: Math.trunc(threshold) } : {}), - type: condition && type !== "subagent" ? "condition" : type + type: condition ? "condition" : type }; }) .filter((item): item is RouterRule => Boolean(item)); @@ -503,15 +502,8 @@ export function parseRouterRuleOperator(value: unknown): RouterRuleOperator | un function routerRuleConditionFromLegacy( type: RouterRuleType, - input: { pattern?: string; threshold?: number } + input: { pattern?: string } ): RouterRuleCondition | undefined { - if (type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(input.threshold ?? "200000") - }; - } if (type === "model-prefix" && input.pattern) { return { left: "request.body.model", @@ -519,27 +511,6 @@ function routerRuleConditionFromLegacy( right: input.pattern }; } - if (type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } return undefined; } @@ -719,7 +690,12 @@ export function normalizeClaudeDesignRoutingRuleDraft(value: unknown, index: num if (!isPlainRecord(value)) { return undefined; } - const type = parseClaudeDesignRouteRuleType(value.type) ?? "model"; + const rawType = stringValue(value.type); + const parsedType = parseClaudeDesignRouteRuleType(value.type); + if (rawType && !parsedType) { + return undefined; + } + const type = parsedType ?? "model"; const target = stringValue(value.target) || composeRouteTargetValue(value.targetProvider, value.targetModel) || @@ -783,9 +759,6 @@ export function normalizeClaudeDesignRuleTypeChange( if (type === "model-prefix" && !rule.pattern.trim()) { patch.pattern = defaults.pattern; } - if (type === "long-context" && !rule.threshold.trim()) { - patch.threshold = "200000"; - } return patch; } @@ -806,16 +779,12 @@ export function isClaudeDesignRoutingDraftValid(draft: ClaudeDesignRoutingDraft) if (rule.type === "model-prefix") { return Boolean(rule.pattern.trim()); } - if (rule.type === "long-context") { - return numberValue(rule.threshold) > 0; - } return true; }); } export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDraft): Record { return { - ...(draft.defaultTarget.trim() ? { default: draft.defaultTarget.trim() } : {}), enabled: draft.enabled, rules: draft.rules.map((rule) => { const output: Record = { @@ -831,9 +800,6 @@ export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDra if (rule.type === "model-prefix") { output.pattern = rule.pattern.trim(); } - if (rule.type === "long-context") { - output.threshold = numberValue(rule.threshold); - } return output; }) }; @@ -863,21 +829,6 @@ export function buildPluginRoutingRows(plugin: AppConfig["plugins"][number], plu const routing = readClaudeDesignRoutingConfig(plugin.config); const baseEnabled = plugin.enabled !== false && routing.enabled; const rows: RoutingRuleRow[] = []; - if (routing.defaultTarget) { - rows.push({ - condition: "always", - enabled: baseEnabled, - key: `plugin-${pluginIndex}-${pluginName}-default`, - name: "Default", - pluginIndex, - readonly: true, - ruleCount: 0, - ruleId: "default", - sourceLabel: `Plugin: ${pluginName}`, - target: routing.defaultTarget, - typeLabel: "Always" - }); - } routing.rules.forEach((rule, ruleIndex) => { rows.push({ condition: formatClaudeDesignRoutingRuleCondition(rule), @@ -915,18 +866,6 @@ export function formatClaudeDesignRoutingRuleCondition(rule: ClaudeDesignRouting if (rule.type === "model-prefix") { return rule.pattern ? `starts with ${rule.pattern}` : "prefix unset"; } - if (rule.type === "long-context") { - return `>${rule.threshold || "threshold"} tokens`; - } - if (rule.type === "thinking") { - return "thinking enabled"; - } - if (rule.type === "web-search") { - return "web_search tool"; - } - if (rule.type === "image") { - return "image content"; - } return "always"; } @@ -940,7 +879,7 @@ export function isClaudeDesignRouteRuleType(value: string): value is ClaudeDesig } export function isClaudeDesignStaticRuleType(type: ClaudeDesignRouteRuleType): boolean { - return type === "always" || type === "image" || type === "thinking" || type === "web-search"; + return type === "always"; } export function claudeDesignRouteRuleTypeLabel(type: ClaudeDesignRouteRuleType): string { diff --git a/src/renderer/pages/home/shared/types.ts b/src/renderer/pages/home/shared/types.ts index 8e1d68e5..11fc81cb 100644 --- a/src/renderer/pages/home/shared/types.ts +++ b/src/renderer/pages/home/shared/types.ts @@ -564,7 +564,7 @@ export type RoutingRewriteDraftRow = { value: string; }; -export type ClaudeDesignRouteRuleType = "always" | "image" | "long-context" | "model" | "model-prefix" | "thinking" | "web-search"; +export type ClaudeDesignRouteRuleType = "always" | "model" | "model-prefix"; export type ClaudeDesignRoutingRuleDraft = { enabled: boolean; diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index b6b4bcf6..c3792ee4 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -556,11 +556,23 @@ .network-table-scroll, .network-detail, + .network-json-fullscreen, + .network-json-fullscreen-body, .network-pane-body { background: var(--network-panel); border-color: var(--network-border); } + .network-json-fullscreen { + color: var(--network-text); + } + + .network-json-fullscreen-header { + background: var(--network-panel-alt); + border-color: var(--network-border); + box-shadow: var(--network-shadow); + } + .network-table-header, .network-kv-header, .network-body-meta { @@ -573,6 +585,41 @@ border-color: var(--network-border-strong); } + .network-column-resize-handle { + appearance: none; + background: transparent; + border: 0; + bottom: 0; + cursor: col-resize; + margin: 0; + outline: none; + padding: 0; + position: absolute; + right: -4px; + top: 0; + width: 8px; + z-index: 1; + } + + .network-column-resize-handle::after { + background: var(--network-border-strong); + bottom: 6px; + content: ""; + left: 50%; + opacity: .62; + position: absolute; + top: 6px; + transform: translateX(-50%); + width: 1px; + } + + .network-column-resize-handle:hover::after, + .network-column-resize-handle:focus-visible::after { + background: var(--network-accent); + opacity: 1; + width: 2px; + } + .network-empty { background: var(--network-panel); color: var(--network-text-subtle); diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index ab47884a..99888a40 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -60,7 +60,7 @@ export class ClaudeCodeRouterPlugin { }; const customModel = await this.resolveCustomRoute(request); - const configuredDecision = resolveConfiguredRouteDecision(request, this.config, tokenCount); + const configuredDecision = resolveConfiguredRouteDecision(request, this.config); if (customModel) { body.model = customModel; } else { @@ -177,8 +177,7 @@ function isPathInside(file: string, root: string): boolean { function resolveConfiguredRouteDecision( request: MutableRequestLike, - config: AppConfig, - tokenCount: number + config: AppConfig ): ConfiguredRouteDecision { const requestedModel = readString(request.body.model); const explicitModel = normalizeRouteSelector(requestedModel); @@ -189,7 +188,7 @@ function resolveConfiguredRouteDecision( const router = config.Router; const rules = router.rules ?? []; for (const rule of rules) { - const decision = resolveRouterRule(rule, request, tokenCount, router); + const decision = resolveRouterRule(rule, request, router); if (decision) { return decision; } @@ -201,7 +200,6 @@ function resolveConfiguredRouteDecision( function resolveRouterRule( rule: RouterRule, request: MutableRequestLike, - tokenCount: number, router: RouterConfig ): ConfiguredRouteDecision | undefined { if (!rule.enabled) { @@ -209,11 +207,6 @@ function resolveRouterRule( } const fallback = rule.fallback ?? router.fallback; - if (rule.type === "subagent") { - const subagentModel = extractSubagentModel(request.body.system); - return subagentModel ? { fallback, model: normalizeRouteSelector(subagentModel), reason: "subagent" } : undefined; - } - const rewrites = routerRuleRewritesFromRule(rule); if (rewrites.length === 0) { return undefined; @@ -225,11 +218,6 @@ function resolveRouterRule( : undefined; } - if (rule.type === "long-context") { - const threshold = rule.threshold || router.longContextThreshold || 200000; - return tokenCount > threshold ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - if (rule.type === "model-prefix") { const pattern = readString(rule.pattern); const requestedModel = readString(request.body.model); @@ -238,18 +226,6 @@ function resolveRouterRule( : undefined; } - if (rule.type === "thinking") { - return request.body.thinking ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - - if (rule.type === "web-search") { - return hasWebSearchTool(request.body.tools) ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - - if (rule.type === "image") { - return hasImageContent(request.body.messages) ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - return undefined; } @@ -731,36 +707,6 @@ function estimateTextTokens(text: string): number { return Math.max(1, Math.ceil((asciiWords + cjkChars) * 1.15)); } -function extractSubagentModel(system: unknown): string | undefined { - if (!Array.isArray(system) || system.length < 2) { - return undefined; - } - const second = system[1]; - if (!isRecord(second) || typeof second.text !== "string") { - return undefined; - } - - const match = second.text.match(/(.*?)<\/CCR-SUBAGENT-MODEL>/s); - if (!match?.[1]) { - return undefined; - } - - second.text = second.text.replace(match[0], ""); - return match[1].trim(); -} - -function hasWebSearchTool(tools: unknown): boolean { - return Array.isArray(tools) && tools.some((tool) => isRecord(tool) && readString(tool.type)?.startsWith("web_search")); -} - -function hasImageContent(messages: unknown): boolean { - if (!Array.isArray(messages)) { - return false; - } - - return messages.some((message) => JSON.stringify(message).includes("\"image\"")); -} - function resolveSessionId(body: Record, headers: Record): string | undefined { const fromHeader = readHeader(headers["x-claude-code-session-id"]) || readHeader(headers["x-claude-session-id"]); if (fromHeader) { diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 7e0e16d4..0e768be6 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -882,10 +882,10 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v ...pluginService.getCoreProviderPlugins() ]); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); - const virtualModelProfiles = withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ + const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ ...(config.virtualModelProfiles ?? []), ...pluginService.getVirtualModelProfiles() - ]))); + ]))), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint); const providers = [ @@ -957,6 +957,98 @@ function writePrivateTextFile(file: string, content: string): void { } } +export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { + return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); +} + +function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppConfig): unknown { + if (!isRecord(profile)) { + return profile; + } + + let nextProfile: Record | undefined; + const baseModel = isRecord(profile.baseModel) ? profile.baseModel : undefined; + const fixedModel = stringValue(baseModel?.fixedModel); + const rewrittenFixedModel = fixedModel + ? rewriteModelSelectorForCoreGatewayProfile(fixedModel, config, "anthropic_messages") + : undefined; + if (baseModel && rewrittenFixedModel && rewrittenFixedModel !== fixedModel) { + nextProfile = { + ...profile, + baseModel: { + ...baseModel, + fixedModel: rewrittenFixedModel + } + }; + } + + const sourceProfile = nextProfile ?? profile; + const metadata = isRecord(sourceProfile.metadata) ? sourceProfile.metadata : undefined; + const fusionVision = isRecord(metadata?.fusionVision) ? metadata.fusionVision : undefined; + const visionBaseUrl = stringValue(fusionVision?.baseUrl); + const visionSelectorField = stringValue(fusionVision?.modelSelector) ? "modelSelector" : stringValue(fusionVision?.model) ? "model" : undefined; + const visionSelector = visionSelectorField ? stringValue(fusionVision?.[visionSelectorField]) : undefined; + const rewrittenVisionSelector = fusionVision && !visionBaseUrl && visionSelector + ? rewriteModelSelectorForCoreGatewayProfile(visionSelector, config, "openai_chat_completions") + : undefined; + + if (metadata && fusionVision && visionSelectorField && rewrittenVisionSelector && rewrittenVisionSelector !== visionSelector) { + nextProfile = { + ...sourceProfile, + metadata: { + ...metadata, + fusionVision: { + ...fusionVision, + [visionSelectorField]: rewrittenVisionSelector + } + } + }; + } + + return nextProfile ?? profile; +} + +function rewriteModelSelectorForCoreGatewayProfile( + model: string, + config: AppConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return undefined; + } + + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + if (!selector) { + return publicModel; + } + + const providerName = coreGatewayProviderSelectorName(selector.provider, clientProtocol); + return providerName ? `${providerName}/${selector.model}` : publicModel; +} + +function coreGatewayProviderSelectorName( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + const explicitCapabilities = normalizedProviderCapabilities(provider); + const protocol = capability?.type ?? (explicitCapabilities.length === 0 ? providerProtocolForClientProtocol(provider, clientProtocol) : undefined); + if (!protocol) { + return undefined; + } + + const credentials = sortProviderCredentialsForConfig(activeProviderCredentials(provider)); + if (credentials.length > 0) { + return providerCredentialInternalName(provider, protocol, credentials[0]); + } + + return capability ? providerCapabilityInternalName(provider, protocol) : providerRuntimeId(provider); +} + function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { const codexAuth = readCodexAuth(); return providerPlugins.map((plugin) => { diff --git a/src/shared/app.ts b/src/shared/app.ts index 6788072b..b3578c73 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -461,12 +461,7 @@ export type GatewayProviderConnectivityCheckReport = { export type RouterRuleType = | "condition" - | "image" - | "long-context" - | "model-prefix" - | "subagent" - | "thinking" - | "web-search"; + | "model-prefix"; export type RouterRuleOperator = | "==" diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs new file mode 100644 index 00000000..4931a1f1 --- /dev/null +++ b/tests/main/gateway-virtual-models.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeCoreGatewayVirtualModelProfiles } from "../../src/server/gateway/service.ts"; + +test("gateway config rewrites Fusion fixed base and vision models to core provider selectors", () => { + const providerName = "Zhipu AI (China) - Coding Plan"; + const config = { + Providers: [ + { + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + capabilities: [ + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "openai_chat_completions" }, + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "anthropic_messages" } + ], + credentials: [{ apiKey: "test-key", id: "test-1" }], + models: ["glm-5.2", "glm-5v-turbo"], + name: providerName, + type: "openai_chat_completions" + } + ], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {} + }; + const profiles = [ + { + baseModel: { fixedModel: `${providerName}/glm-5.2`, mode: "fixed" }, + displayName: "GLM Fusion", + enabled: true, + execution: { + clientToolsPolicy: "allow", + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "glm-fusion", + key: "glm-fusion", + match: { exactAliases: ["glm-fusion"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionVision: { + modelSelector: `${providerName}/glm-5v-turbo`, + toolName: "vision_understand_glm_fusion" + } + }, + tools: [{ name: "vision_understand_glm_fusion", visibility: "internal" }] + } + ]; + + const [profile] = normalizeCoreGatewayVirtualModelProfiles(profiles, config); + + assert.match( + profile.baseModel.fixedModel, + /^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::anthropic_messages::cred:test-1\/glm-5\.2$/ + ); + assert.match( + profile.metadata.fusionVision.modelSelector, + /^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::openai_chat_completions::cred:test-1\/glm-5v-turbo$/ + ); + assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`); +});