Simplify routing config and improve network logs

This commit is contained in:
musistudio
2026-06-30 20:02:35 +08:00
parent 98375b7f4e
commit 98cd66f261
15 changed files with 633 additions and 296 deletions
+4 -4
View File
@@ -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
+5 -39
View File
@@ -1171,7 +1171,7 @@ function parseRouter(value: unknown): Partial<RouterConfig> | undefined {
}
const router: Partial<RouterConfig> = {};
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;
}
@@ -303,13 +303,6 @@ export function ConfigureClaudeDesignDialog({
<Field label={t("Model routing")}>
<Toggle checked={draft.enabled} onChange={(enabled) => onChange({ enabled })} />
</Field>
<Field label={t("Default target model")}>
<RouteTargetControl
modelOptions={modelOptions}
onChange={(defaultTarget) => onChange({ defaultTarget })}
value={draft.defaultTarget}
/>
</Field>
</div>
<div className="space-y-2">
@@ -350,11 +343,6 @@ export function ConfigureClaudeDesignDialog({
<Input value={rule.pattern} onChange={(event) => onChangeRule(index, { pattern: event.target.value })} />
</Field>
) : null}
{rule.type === "long-context" ? (
<Field label={t("Token threshold")}>
<Input type="number" value={rule.threshold} onChange={(event) => onChangeRule(index, { threshold: event.target.value })} />
</Field>
) : null}
{isClaudeDesignStaticRuleType(rule.type) ? (
<div className="flex min-h-[58px] items-end rounded-md border border-border/70 bg-muted/25 px-3 py-2 text-[12px] text-muted-foreground">{t(claudeDesignRouteRuleTypeLabel(rule.type))}</div>
) : null}
@@ -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<string, ReturnType<typeof formatLogBodyView>>();
type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration";
type LogTableColumn = {
id: LogTableColumnId;
minWidth: number;
};
type LogTableColumnWidths = Partial<Record<LogTableColumnId, number>>;
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<Record<number, RequestLogEntry>>({});
const [detailErrorById, setDetailErrorById] = useState<Record<number, string>>({});
const [detailLoadingId, setDetailLoadingId] = useState<number>();
const [logColumnWidths, setLogColumnWidths] = useState<LogTableColumnWidths>({});
const logTableHeaderRef = useRef<HTMLDivElement>(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<HTMLButtonElement>) {
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 (
<motion.div
animate={{ opacity: 1 }}
@@ -431,14 +508,19 @@ export function LogsView({
<div className="flex min-h-0 flex-1 flex-col">
<div className="network-table-scroll min-h-0 flex-1 overflow-auto">
<div className="w-full min-w-0">
<div className={cn("network-table-header sticky top-0 z-10 grid h-9 items-center border-b text-[12px] font-semibold", logTableGridClass)}>
<NetworkHeaderCell label={t("时间")} />
<NetworkHeaderCell label={t("状态")} />
<NetworkHeaderCell label={t("Stream")} />
<NetworkHeaderCell label={t("模型")} />
{hasAnyCredentialInfo ? <NetworkHeaderCell label={t("Credential")} /> : null}
<NetworkHeaderCell label={t("令牌")} />
<NetworkHeaderCell label={t("持续时间")} />
<div
className={cn("network-table-header sticky top-0 z-10 grid h-9 items-center border-b text-[12px] font-semibold", logTableGridClass)}
ref={logTableHeaderRef}
style={logTableGridStyle}
>
{visibleLogColumns.map((column, index) => (
<NetworkHeaderCell
key={column.id}
label={logTableColumnLabel(column.id, t)}
onResizeStart={index < visibleLogColumns.length - 1 ? (event) => startLogColumnResize(index, event) : undefined}
resizeLabel={t("Resize column width")}
/>
))}
</div>
{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"
>
<div className="truncate px-3 font-mono text-[11px]" title={createdAt}>
@@ -707,6 +838,7 @@ function LogJsonPanel({
const t = useAppText();
const [selectedTab, setSelectedTab] = useState<LogPayloadTab>("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({
<div className="network-pane-body flex min-h-0 flex-1 flex-col overflow-hidden">
{selectedTab === "body" ? (
<>
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
<div className="relative min-w-[180px] flex-1">
<Search className="network-search-icon pointer-events-none absolute left-2 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" />
<input
aria-label={`${t("Filter")} ${title} JSON`}
className="network-filter-input h-6 w-full rounded border pl-7 pr-2 text-[11px] font-semibold outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("筛选 JSON...")}
value={query}
/>
</div>
{bodyView.json !== undefined && query.trim() === "" ? (
<button
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
onClick={() => setPreferTextBody((current) => !current)}
type="button"
>
{preferTextBody ? "JSON" : t("Show full content")}
</button>
) : null}
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
</div>
<LogBodyViewer copyLabel={`${t("Copy")} ${title} ${t("body")}`} copyText={formatted}>
{showJsonTree ? (
<LogJsonTree expandedPaths={expandedJsonPaths} onToggle={toggleJsonPath} value={bodyView.json} />
) : (
<pre className="network-code min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-3 pr-12 font-mono text-[11px] leading-5">{visible}</pre>
)}
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onQueryChange={setQuery}
onToggleTextBody={() => setPreferTextBody((current) => !current)}
preferTextBody={preferTextBody}
query={query}
title={title}
/>
<LogBodyViewer
copyLabel={`${t("Copy")} ${title} ${t("body")}`}
copyText={formatted}
fullscreenLabel={t("Open fullscreen JSON viewer")}
onFullscreen={() => setFullscreenOpen(true)}
>
<LogJsonBodyContent
expandedJsonPaths={expandedJsonPaths}
onToggleJsonPath={toggleJsonPath}
showJsonTree={showJsonTree}
value={bodyView.json}
visible={visible}
/>
</LogBodyViewer>
{fullscreenOpen ? (
<LogJsonFullscreenViewer
body={body}
bodyView={bodyView}
copyLabel={`${t("Copy")} ${title} ${t("body")}`}
copyText={formatted}
expandedJsonPaths={expandedJsonPaths}
onClose={() => 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}
</>
) : (
<div className="min-h-0 flex-1 overflow-auto">
@@ -798,6 +956,153 @@ function LogJsonPanel({
);
}
function LogJsonBodyToolbar({
body,
bodyView,
onQueryChange,
onToggleTextBody,
preferTextBody,
query,
title
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
onQueryChange: (value: string) => void;
onToggleTextBody: () => void;
preferTextBody: boolean;
query: string;
title: string;
}) {
const t = useAppText();
return (
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
<div className="relative min-w-[180px] flex-1">
<Search className="network-search-icon pointer-events-none absolute left-2 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" />
<input
aria-label={`${t("Filter")} ${title} JSON`}
className="network-filter-input h-6 w-full rounded border pl-7 pr-2 text-[11px] font-semibold outline-none"
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("筛选 JSON...")}
value={query}
/>
</div>
{bodyView.json !== undefined && query.trim() === "" ? (
<button
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
onClick={onToggleTextBody}
type="button"
>
{preferTextBody ? "JSON" : t("Show full content")}
</button>
) : null}
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
</div>
);
}
function LogJsonBodyContent({
expandedJsonPaths,
onToggleJsonPath,
showJsonTree,
value,
visible
}: {
expandedJsonPaths: Set<string>;
onToggleJsonPath: (path: string) => void;
showJsonTree: boolean;
value: unknown;
visible: string;
}) {
return showJsonTree ? (
<LogJsonTree expandedPaths={expandedJsonPaths} onToggle={onToggleJsonPath} value={value} />
) : (
<pre className="network-code min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-3 pr-20 font-mono text-[11px] leading-5">{visible}</pre>
);
}
function LogJsonFullscreenViewer({
body,
bodyView,
copyLabel,
copyText,
expandedJsonPaths,
onClose,
onQueryChange,
onToggleJsonPath,
onToggleTextBody,
preferTextBody,
query,
showJsonTree,
subtitle,
title,
value,
visible
}: {
body?: RequestLogBody;
bodyView: ReturnType<typeof formatLogBodyView>;
copyLabel: string;
copyText: string;
expandedJsonPaths: Set<string>;
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 (
<div
aria-label={`${title} ${t("Fullscreen JSON viewer")}`}
aria-modal="true"
className="network-json-fullscreen fixed inset-0 z-[80] flex min-h-0 flex-col"
role="dialog"
>
<div className="network-json-fullscreen-header flex h-12 min-w-0 shrink-0 items-center gap-3 border-b px-4">
<span className="network-pane-title min-w-0 truncate text-[15px] font-bold">{title}</span>
{subtitle ? <span className="network-muted shrink-0 text-[12px] font-semibold">{subtitle}</span> : null}
<button
aria-label={t("Close fullscreen JSON viewer")}
className="network-control-button ml-auto flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onClose}
title={t("Close")}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<LogJsonBodyToolbar
body={body}
bodyView={bodyView}
onQueryChange={onQueryChange}
onToggleTextBody={onToggleTextBody}
preferTextBody={preferTextBody}
query={query}
title={title}
/>
<div className="network-json-fullscreen-body flex min-h-0 flex-1">
<LogBodyViewer copyLabel={copyLabel} copyText={copyText}>
<LogJsonBodyContent
expandedJsonPaths={expandedJsonPaths}
onToggleJsonPath={onToggleJsonPath}
showJsonTree={showJsonTree}
value={value}
visible={visible}
/>
</LogBodyViewer>
</div>
</div>
);
}
function logBodyCacheKey(body: RequestLogBody | undefined): string {
if (!body) {
return "missing";
@@ -901,11 +1206,15 @@ function jsonContainerHiddenSummary(value: Record<string, unknown> | 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 (
<div className="relative flex min-h-0 flex-1">
<button
aria-label={copyLabel}
className={cn(
"network-control-button absolute right-2 top-2 z-10 flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
copied && "network-json-copy-success"
)}
onClick={() => void copyBody()}
title={copied ? t("Copied") : t("复制")}
type="button"
>
<AnimatedIconSwap iconKey={copied ? "copied" : "copy"}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</AnimatedIconSwap>
</button>
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
{onFullscreen ? (
<button
aria-label={fullscreenLabel ?? t("Open fullscreen JSON viewer")}
className="network-control-button flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onFullscreen}
title={fullscreenLabel ?? t("Open fullscreen JSON viewer")}
type="button"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label={copyLabel}
className={cn(
"network-control-button flex h-7 w-7 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
copied && "network-json-copy-success"
)}
onClick={() => void copyBody()}
title={copied ? t("Copied") : t("复制")}
type="button"
>
<AnimatedIconSwap iconKey={copied ? "copied" : "copy"}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</AnimatedIconSwap>
</button>
</div>
{children}
</div>
);
@@ -954,7 +1276,7 @@ function LogJsonTree({
value: unknown;
}) {
return (
<div className="network-code min-h-0 flex-1 overflow-auto p-3 pr-12 font-mono text-[11px] leading-5">
<div className="network-code min-h-0 flex-1 overflow-auto p-3 pr-20 font-mono text-[11px] leading-5">
<JsonTreeNode expandedPaths={expandedPaths} onToggle={onToggle} path="$" value={value} />
</div>
);
@@ -1091,10 +1413,27 @@ function JsonPrimitiveValue({ value }: { value: unknown }) {
return <span>{String(value)}</span>;
}
function NetworkHeaderCell({ label }: { label: string }) {
function NetworkHeaderCell({
label,
onResizeStart,
resizeLabel
}: {
label: string;
onResizeStart?: (event: ReactPointerEvent<HTMLButtonElement>) => void;
resizeLabel?: string;
}) {
return (
<div className="network-header-cell min-w-0 border-l px-2 first:border-l-0">
<span className="truncate">{label}</span>
<div className={cn("network-header-cell relative flex h-full min-w-0 items-center border-l px-2 first:border-l-0", onResizeStart && "pr-3")}>
<span className="min-w-0 truncate">{label}</span>
{onResizeStart ? (
<button
aria-label={resizeLabel ?? label}
className="network-column-resize-handle"
onPointerDown={onResizeStart}
title={resizeLabel ?? label}
type="button"
/>
) : null}
</div>
);
}
+2 -2
View File
@@ -697,12 +697,12 @@ export function wrapperPluginCapability(item: Record<string, unknown>): 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");
}
+8
View File
@@ -320,6 +320,10 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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": "调整组件宽度",
+2 -11
View File
@@ -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" }
];
@@ -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: "<CCR-SUBAGENT-MODEL>"
};
}
return undefined;
}
+10 -71
View File
@@ -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<string, unknown> {
return {
...(draft.defaultTarget.trim() ? { default: draft.defaultTarget.trim() } : {}),
enabled: draft.enabled,
rules: draft.rules.map((rule) => {
const output: Record<string, unknown> = {
@@ -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 {
+1 -1
View File
@@ -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;
+47
View File
@@ -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);
@@ -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>(.*?)<\/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<string, unknown>, headers: Record<string, HeaderValue>): string | undefined {
const fromHeader = readHeader(headers["x-claude-code-session-id"]) || readHeader(headers["x-claude-session-id"]);
if (fromHeader) {
+94 -2
View File
@@ -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<string, unknown> | 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) => {
+1 -6
View File
@@ -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 =
| "=="
@@ -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`);
});