mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
Simplify routing config and improve network logs
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "调整组件宽度",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user