Refactor router config and provider handling

This commit is contained in:
musistudio 2026-07-01 17:55:48 +08:00
parent 73bcf2e6e6
commit 56c1b666c6
20 changed files with 1625 additions and 176 deletions

View file

@ -36,6 +36,7 @@ import type {
ProfileRuntimeConfig,
ProxyRouteTarget,
ProxyRuntimeConfig,
RouterBuiltInRulesConfig,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -965,6 +966,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
const models = Array.isArray(item.models)
? item.models.map((model) => readString(model)).filter((model): model is string => Boolean(model))
: [];
const modelDescriptions = parseModelDescriptions(item.modelDescriptions ?? item.model_descriptions, models);
const modelDisplayNames = parseModelDisplayNames(item.modelDisplayNames ?? item.model_display_names, models);
if (!name) {
@ -986,6 +988,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
extraHeaders: item.extraHeaders,
icon: readString(item.icon),
id: readString(item.id),
modelDescriptions,
modelDisplayNames,
models,
name,
@ -1000,6 +1003,22 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
return withProviderIds(providers);
}
function parseModelDescriptions(value: unknown, models: string[]): Record<string, string> | undefined {
if (!isObject(value)) {
return undefined;
}
const modelIds = new Set(models);
const entries = Object.entries(value)
.map(([rawModel, rawDescription]) => [rawModel.trim(), readString(rawDescription)] as const)
.filter((entry): entry is [string, string] => {
const [model, description] = entry;
return Boolean(model && description && modelIds.has(model));
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function parseModelDisplayNames(value: unknown, models: string[]): Record<string, string> | undefined {
if (!isObject(value)) {
return undefined;
@ -1171,15 +1190,13 @@ function parseRouter(value: unknown): Partial<RouterConfig> | undefined {
}
const router: Partial<RouterConfig> = {};
for (const key of ["background", "default"] as const) {
const route = readString(value[key]);
if (route) {
router[key] = route;
}
const defaultRoute = readString(value.default);
if (defaultRoute) {
router.default = defaultRoute;
}
const threshold = readNumber(value.longContextThreshold);
if (threshold !== undefined && threshold > 0) {
router.longContextThreshold = threshold;
const builtInRules = parseRouterBuiltInRules(value.builtInRules ?? value.builtinRules ?? value.agentRules);
if (builtInRules) {
router.builtInRules = builtInRules;
}
const rules = parseRouterRules(value.rules);
if (rules) {
@ -1194,6 +1211,29 @@ function parseRouter(value: unknown): Partial<RouterConfig> | undefined {
return router;
}
function parseRouterBuiltInRules(value: unknown): RouterBuiltInRulesConfig | undefined {
if (!isObject(value)) {
return undefined;
}
return {
"claude-code": parseRouterBuiltInAgentRule(value["claude-code"] ?? value.claudeCode ?? value.claude),
codex: parseRouterBuiltInAgentRule(value.codex)
};
}
function parseRouterBuiltInAgentRule(value: unknown): { enabled: boolean } {
if (typeof value === "boolean") {
return { enabled: value };
}
if (!isObject(value)) {
return { enabled: true };
}
return {
enabled: typeof value.enabled === "boolean" ? value.enabled : true
};
}
function parseRouterFallback(value: unknown): RouterFallbackConfig | undefined {
if (!isObject(value)) {
return undefined;

View file

@ -17,9 +17,9 @@ import {
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
isTraySupportedPlatform,
isRoutingRewriteDraftRowValid,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDisplayNamesForModels,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
providerCredentialsFromDraft,
@ -1157,6 +1157,7 @@ function App() {
return {
...next,
modelDescriptions: patch.modelDescriptions ?? current.modelDescriptions,
modelDisplayNames: patch.modelDisplayNames,
modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"),
selectedModels: [],
@ -1368,6 +1369,7 @@ function App() {
}
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol];
const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
const selectedProtocolSet = new Set(protocolsToSave);
const capabilityCandidates = mergeProviderCapabilities(
@ -1436,6 +1438,7 @@ function App() {
account: accountConfig,
credentials: credentials.length > 0 ? credentials : undefined,
icon: providerDraft.icon.trim() || undefined,
modelDescriptions,
modelDisplayNames,
models,
name: providerName,
@ -1518,6 +1521,27 @@ function App() {
return persistConfig(next, setActionError);
}
function updateProviderModelDescription(providerIndex: number, model: string, description: string) {
const next = buildConfigUpdate((config) => {
const provider = config.Providers[providerIndex];
const models = provider ? mergeProviderModelLists(provider.models) : [];
if (!provider || !models.includes(model)) {
return config;
}
const descriptions = { ...(provider.modelDescriptions ?? {}) };
const trimmed = description.trim();
if (trimmed) {
descriptions[model] = trimmed;
} else {
delete descriptions[model];
}
provider.modelDescriptions = modelDescriptionsForModels(descriptions, models);
return config;
});
setConfigDraft(next);
void persistConfig(next, setActionError);
}
async function confirmProviderDelete() {
if (providerDeleteIndex === undefined) {
return;
@ -2807,7 +2831,8 @@ function App() {
updateFilter: updateRequestLogFilter
},
models: {
config: draftConfig
config: draftConfig,
updateModelDescription: updateProviderModelDescription
},
networking: {
clearCaptures: () => void clearProxyNetworkCaptures(),
@ -2866,6 +2891,15 @@ function App() {
moveRule: moveRoutingRule,
providers: draftConfig.Providers,
removeRule: setRoutingDeleteIndex,
updateBuiltInRule: (agent, enabled) => updateConfig((config) => {
config.Router.builtInRules = normalizeRouterBuiltInRules(config.Router.builtInRules);
if (agent === "claude-code") {
config.Router.builtInRules["claude-code"] = { enabled };
} else {
config.Router.builtInRules.codex = { enabled };
}
return config;
}),
updateFallback: (fallback) => updateConfig((config) => {
config.Router.fallback = normalizeRouterFallbackConfig(fallback);
return config;

View file

@ -1063,41 +1063,43 @@ function LogJsonFullscreenViewer({
<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"
className="network-json-fullscreen fixed inset-0 z-[100] flex min-h-0"
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 className="network-json-fullscreen-panel flex min-h-0 flex-1 flex-col overflow-hidden border">
<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>
</div>
);

View file

@ -250,8 +250,22 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
);
}
export function ModelsView({ config }: { config: AppConfig }) {
export function ModelsView({
config,
updateModelDescription
}: {
config: AppConfig;
updateModelDescription: (providerIndex: number, model: string, description: string) => void;
}) {
const t = useAppText();
const [descriptionDraft, setDescriptionDraft] = useState("");
const [descriptionTarget, setDescriptionTarget] = useState<{
description: string;
displayName?: string;
model: string;
providerIndex: number;
providerName?: string;
}>();
const [query, setQuery] = useState("");
const rows = useMemo(() => createModelCatalogItems(config), [config]);
const normalizedQuery = query.trim().toLowerCase();
@ -260,6 +274,34 @@ export function ModelsView({ config }: { config: AppConfig }) {
[normalizedQuery, rows]
);
function openDescriptionDialog(row: (typeof rows)[number]) {
if (row.providerIndex === undefined) {
return;
}
const description = row.description ?? "";
setDescriptionDraft(description);
setDescriptionTarget({
description,
displayName: row.displayName,
model: row.model,
providerIndex: row.providerIndex,
providerName: row.providerName
});
}
function closeDescriptionDialog() {
setDescriptionDraft("");
setDescriptionTarget(undefined);
}
function saveDescriptionDialog() {
if (!descriptionTarget) {
return;
}
updateModelDescription(descriptionTarget.providerIndex, descriptionTarget.model, descriptionDraft);
closeDescriptionDialog();
}
return (
<motion.div
animate={{ opacity: 1 }}
@ -297,21 +339,55 @@ export function ModelsView({ config }: { config: AppConfig }) {
) : null}
{visibleRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="min-w-[360px]">
<div className="sticky top-0 z-10 grid h-10 grid-cols-[minmax(0,1fr)] items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="min-w-[680px]">
<div className="sticky top-0 z-10 grid h-10 grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="truncate">{t("Model")}</div>
<div className="truncate">{t("Description")}</div>
</div>
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleRows.map((row) => (
<AnimatedListItem
className="grid min-h-[48px] grid-cols-[minmax(0,1fr)] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
className="grid min-h-[76px] grid-cols-[minmax(0,1fr)_minmax(260px,1.5fr)] items-start gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold text-foreground" title={row.displayName ?? row.model}>
{row.displayName ?? row.model}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground" title={row.providerName ? `${row.providerName}/${row.model}` : row.model}>
{row.providerName ? `${row.providerName}/${row.model}` : row.model}
</div>
{row.providerName ? (
<Badge className="mt-1 max-w-full" variant="outline">
<span className="truncate">{row.providerName}</span>
</Badge>
) : null}
</div>
<div className="min-w-0">
{row.providerIndex !== undefined ? (
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-[12px] leading-5 text-muted-foreground" title={row.description ?? ""}>
{row.description || "-"}
</div>
</div>
<Button
aria-label={`${t("Edit description")} ${row.displayName ?? row.model}`}
className="h-7 w-7 shrink-0 p-0"
onClick={() => openDescriptionDialog(row)}
title={t("Edit description")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="line-clamp-2 text-[12px] leading-5 text-muted-foreground" title={row.description ?? ""}>
{row.description || "-"}
</div>
)}
</div>
</AnimatedListItem>
))}
@ -322,10 +398,74 @@ export function ModelsView({ config }: { config: AppConfig }) {
) : null}
</CardContent>
</Card>
<ModelCatalogDescriptionDialog
draft={descriptionDraft}
onChange={setDescriptionDraft}
onClose={closeDescriptionDialog}
onSave={saveDescriptionDialog}
target={descriptionTarget}
/>
</motion.div>
);
}
function ModelCatalogDescriptionDialog({
draft,
onChange,
onClose,
onSave,
target
}: {
draft: string;
onChange: (value: string) => void;
onClose: () => void;
onSave: () => void;
target?: {
description: string;
displayName?: string;
model: string;
providerName?: string;
};
}) {
const t = useAppText();
const open = Boolean(target);
const title = target?.displayName || target?.model || t("Model");
const subtitle = target?.providerName ? `${target.providerName}/${target.model}` : target?.model;
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent className="max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("Edit description")}</DialogTitle>
</DialogHeader>
<DialogBody className="space-y-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground" title={title}>{title}</div>
{subtitle ? <div className="truncate font-mono text-[11px] text-muted-foreground" title={subtitle}>{subtitle}</div> : null}
</div>
<Field label={t("Description")}>
<Textarea
aria-label={`${t("Description")} ${title}`}
className="min-h-[160px] resize-y text-[12px]"
onChange={(event) => onChange(event.target.value)}
placeholder={t("Describe model strengths, tradeoffs, and best-fit tasks.")}
value={draft}
/>
</Field>
</DialogBody>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">
{t("Cancel")}
</Button>
<Button disabled={draft.trim() === (target?.description ?? "").trim()} onClick={onSave} type="button">
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayProviderConfig; snapshots: ProviderAccountSnapshot[] }) {
const t = useAppText();
const sortedSnapshots = [...snapshots].sort(compareProviderAccountSnapshots);
@ -1059,6 +1199,7 @@ function LocalAgentProviderImportPanel({
baseUrl: result.provider.baseUrl,
credentials: [],
icon: result.provider.icon ?? "",
modelDescriptions: result.provider.modelDescriptions,
modelDisplayNames: result.provider.modelDisplayNames,
modelSearch: "",
modelsText: result.provider.models.join("\n"),
@ -1237,11 +1378,12 @@ export function AddProviderForm({
...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id }))
];
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
const configuredModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
const hasConnectivityCheckInputs = Boolean(
!localAgentImport &&
draft.baseUrl.trim() &&
draft.apiKey.trim() &&
mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)).length > 0
configuredModels.length > 0
);
useEffect(() => {
@ -1295,6 +1437,7 @@ export function AddProviderForm({
...createDefaultProviderAccountDraft(),
baseUrl: "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
presetId,
@ -1310,6 +1453,7 @@ export function AddProviderForm({
...createDefaultProviderAccountDraft(),
baseUrl: "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
presetId,
@ -1329,6 +1473,7 @@ export function AddProviderForm({
...accountDraft,
baseUrl: endpoint?.baseUrl ?? "",
icon: "",
modelDescriptions: undefined,
modelDisplayNames: preset?.defaultModelDisplayNames,
modelSearch: "",
modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "",
@ -1437,6 +1582,12 @@ export function AddProviderForm({
value={splitLines(draft.modelsText)}
/>
)}
<ModelDescriptionsEditor
descriptions={draft.modelDescriptions}
displayNames={draft.modelDisplayNames}
models={configuredModels}
onChange={(modelDescriptions) => onChange({ modelDescriptions })}
/>
</Field>
<div className="sm:col-span-2 flex min-w-0 flex-wrap items-center justify-between gap-2 text-[12px] text-muted-foreground">
<div className="min-w-0 flex-1">
@ -2439,6 +2590,62 @@ function ModelTagInput({
);
}
function ModelDescriptionsEditor({
descriptions,
displayNames,
models,
onChange
}: {
descriptions?: Record<string, string>;
displayNames?: Record<string, string>;
models: string[];
onChange: (value: Record<string, string> | undefined) => void;
}) {
const t = useAppText();
const normalizedModels = mergeProviderModelLists(models);
if (normalizedModels.length === 0) {
return null;
}
function updateDescription(model: string, value: string) {
const next: Record<string, string> = {};
for (const item of normalizedModels) {
const description = (item === model ? value : descriptions?.[item] ?? "").trim();
if (description) {
next[item] = description;
}
}
onChange(Object.keys(next).length > 0 ? next : undefined);
}
return (
<div className="space-y-2 rounded-md border border-border bg-muted/20 p-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="block truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{t("Model descriptions")}</span>
<span className="shrink-0 text-[11px] leading-4 text-muted-foreground/75">{t("Used in Agent routing prompts")}</span>
</div>
<div className="grid grid-cols-1 gap-2">
{normalizedModels.map((model) => {
const label = displayNames?.[model]?.trim() || model;
return (
<div className="grid grid-cols-1 gap-1 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)] sm:items-start" key={model}>
<Label className="min-h-8 min-w-0 pt-1.5 text-[12px] font-medium text-foreground" title={model}>
<span className="block truncate">{label}</span>
</Label>
<Textarea
className="min-h-[58px] resize-y text-[12px]"
onChange={(event) => updateDescription(model, event.target.value)}
placeholder={t("Describe model strengths, tradeoffs, and best-fit tasks.")}
value={descriptions?.[model] ?? ""}
/>
</div>
);
})}
</div>
</div>
);
}
function ModelMultiSelect({
displayNames,
models,

View file

@ -4,8 +4,8 @@ import {
CardHeader, Check, CircleAlert, clampNumber, cn, createRouteModelOptions, createRoutingRewriteDraftRow,
Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle,
disclosureSpringTransition, Field, formatRouterRuleCondition, formatRouterRuleTarget, GatewayProviderConfig, Input,
motion, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
Info, motion, normalizeRouterFallbackConfig, Pencil, Plus, Route, RouterFallbackConfig,
RouterBuiltInAgentRuleId, RouterFallbackMode, routerConditionSourceOptions, routerFallbackModeOptions, RouterRule, routerRewriteOperationOptions, routerRuleOperatorOptions,
RouteTargetControl, routingRuleRowMatchesQuery, Search, SelectControl, Toggle, translateOptions,
Trash2, uniqueStrings, useAppText, useMemo, useState, X
} from "../shared";
@ -17,6 +17,7 @@ export function RoutingView({
moveRule,
providers,
removeRule,
updateBuiltInRule,
updateFallback,
updateRule
}: {
@ -26,13 +27,14 @@ export function RoutingView({
moveRule: (index: number, direction: -1 | 1) => void;
providers: GatewayProviderConfig[];
removeRule: (index: number) => void;
updateBuiltInRule: (agent: RouterBuiltInAgentRuleId, enabled: boolean) => void;
updateFallback: (fallback: RouterFallbackConfig) => void;
updateRule: (index: number, patch: Partial<RouterRule>) => void;
}) {
const t = useAppText();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const rows = useMemo(() => buildRoutingRuleRows(config), [config.Router.rules]);
const rows = useMemo(() => buildRoutingRuleRows(config), [config]);
const fallback = config.Router.fallback;
const visibleRules = useMemo(
() => rows.filter((row) => routingRuleRowMatchesQuery(row, normalizedQuery)),
@ -90,66 +92,87 @@ export function RoutingView({
<div className="truncate">{t("Condition")}</div>
<div className="truncate">{t("Request action")}</div>
<div className="truncate">{t("Status")}</div>
<div aria-hidden="true" />
<div className="truncate text-right">{t("Action")}</div>
</div>
<div className="divide-y divide-border/60">
<AnimatePresence initial={false}>
{visibleRules.map((row) => (
<AnimatedListItem
className="grid min-h-[58px] grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(240px,1.15fr)_84px_148px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
{visibleRules.map((row) => {
const rowSourceLabel = row.builtInAgent ? t(row.sourceLabel) : row.sourceLabel;
const rowTarget = row.target === "Profile model unset" ? t(row.target) : row.target;
return (
<AnimatedListItem
className="grid min-h-[58px] grid-cols-[minmax(160px,0.8fr)_minmax(220px,1fr)_minmax(240px,1.15fr)_84px_148px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
key={row.key}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[12px] font-semibold">{row.name || t("Unnamed")}</div>
{row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
{row.builtInAgent ? <BuiltInRouteInfoIcon description={builtInRouteDescription(row.builtInAgent, t)} /> : null}
{row.builtInAgent ? <Badge variant="outline">{t("Built-in")}</Badge> : row.readonly ? <Badge variant="outline">{t("Plugin")}</Badge> : null}
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={`${row.sourceLabel}: ${row.ruleId}`}>
{row.sourceLabel}: {row.ruleId}
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={`${rowSourceLabel}: ${row.ruleId}`}>
{rowSourceLabel}: {row.ruleId}
</div>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
{!row.builtInAgent ? (
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline">{t(row.typeLabel)}</Badge>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={row.condition}>
{row.condition}
</span>
</div>
) : null}
</div>
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={row.target}>
{row.target}
<div className="min-w-0 truncate font-mono text-[11px] text-muted-foreground" title={row.builtInAgent ? undefined : rowTarget}>
{row.builtInAgent ? null : rowTarget}
</div>
<div className="flex min-w-0 items-center gap-2">
<Toggle checked={row.enabled} disabled={row.readonly} onChange={(enabled) => row.index !== undefined && updateRule(row.index, { enabled })} />
</div>
<div className="flex items-center justify-end gap-1">
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
<Toggle
checked={row.enabled}
disabled={row.readonly || row.toggleDisabled}
onChange={(enabled) => {
if (row.builtInAgent) {
updateBuiltInRule(row.builtInAgent, enabled);
} else if (row.index !== undefined) {
updateRule(row.index, { enabled });
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
/>
</div>
</AnimatedListItem>
))}
<div className="flex items-center justify-end gap-1">
{!row.builtInAgent ? (
<>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("up")}`} disabled={row.readonly || row.index === undefined || row.index === 0} onClick={() => row.index !== undefined && moveRule(row.index, -1)} size="iconSm" title={t("Move up")} type="button" variant="ghost">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Move")} ${row.name || t("rule")} ${t("down")}`} disabled={row.readonly || row.index === undefined || row.index === row.ruleCount - 1} onClick={() => row.index !== undefined && moveRule(row.index, 1)} size="iconSm" title={t("Move down")} type="button" variant="ghost">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
aria-label={`${t("Edit")} ${row.name || t("rule")}`}
disabled={row.readonly || row.index === undefined}
onClick={() => {
if (row.index !== undefined) {
editRule(row.index);
}
}}
size="iconSm"
title={t("Edit rule")}
type="button"
variant="ghost"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button aria-label={`${t("Remove")} ${row.name || t("rule")}`} disabled={row.readonly || row.index === undefined} onClick={() => row.index !== undefined && removeRule(row.index)} size="iconSm" title={t("Remove rule")} type="button" variant="ghost">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
) : null}
</div>
</AnimatedListItem>
);
})}
</AnimatePresence>
</div>
</div>
@ -161,6 +184,24 @@ export function RoutingView({
);
}
function BuiltInRouteInfoIcon({ description }: { description: string }) {
return (
<span aria-label={description} className="group relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30" tabIndex={0} title={description}>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
<span className="pointer-events-none absolute left-full top-1/2 z-[80] ml-2 hidden w-[260px] -translate-y-1/2 rounded-md border border-border bg-popover px-2.5 py-2 text-left text-[11px] font-medium leading-4 text-popover-foreground shadow-card group-hover:block group-focus:block">
{description}
</span>
</span>
);
}
function builtInRouteDescription(agent: RouterBuiltInAgentRuleId, t: (value: string) => string): string {
if (agent === "claude-code") {
return t("Routes Claude Code requests by matching the Claude user-agent and setting request.body.model to the Claude Code profile or default model.");
}
return t("Routes Codex requests by matching the Codex user-agent and setting request.body.model to the Codex profile or default model.");
}
function RouterFallbackControl({
className,
fallback,

View file

@ -643,6 +643,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Action": "操作",
"Check trust": "检查信任",
"Choose where each agent uses CCR.": "选择每个 Agent 在哪里使用 CCR。",
"Built-in": "内置",
"Routes Claude Code requests by matching the Claude user-agent and setting request.body.model to the Claude Code profile or default model.": "通过匹配 Claude user-agent 路由 Claude Code 请求,并将 request.body.model 设置为 Claude Code 配置档案模型或默认模型。",
"Routes Codex requests by matching the Codex user-agent and setting request.body.model to the Codex profile or default model.": "通过匹配 Codex user-agent 路由 Codex 请求,并将 request.body.model 设置为 Codex 配置档案模型或默认模型。",
"Click Check Connection to verify connectivity with a real model request.": "点击检测连通性,用一次真实模型请求验证是否可用。",
"Click Add to create one": "点击添加创建一项",
"Click Install to add one": "点击安装添加一项",
@ -828,10 +831,12 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Request log database": "请求日志数据库",
"Method": "方法",
"Model": "模型",
"Model descriptions": "模型描述",
"Model override": "模型覆盖",
"Model routing": "模型路由",
"Model prefix": "模型前缀",
"Models": "模型",
"Describe model strengths, tradeoffs, and best-fit tasks.": "描述模型优势、取舍和最适合的任务。",
"Module path": "模块路径",
"Name": "名称",
"Networking": "网络",
@ -877,6 +882,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Phone Bluetooth target": "手机蓝牙目标",
"Phone Wi-Fi target": "手机 Wi-Fi 目标",
"Plugin": "插件",
"Profile model unset": "未设置配置档案模型",
"Plugin apps must be a JSON array.": "插件 App 必须是 JSON 数组。",
"Plugin config JSON": "插件配置 JSON",
"Plugin config must be a JSON object.": "插件配置必须是 JSON 对象。",
@ -1348,6 +1354,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Detecting icon": "正在检测图标",
"Detecting provider": "正在检测供应商",
"Disabled": "已禁用",
"Edit description": "编辑描述",
"Expand": "展开",
"Expand models": "展开模型",
"Expires": "过期",
@ -1367,6 +1374,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"More": "更多",
"Provider models": "供应商模型",
"Runtime provider": "运行时供应商",
"Used in Agent routing prompts": "用于 Agent 路由提示词",
"Read only": "只读",
"Search all models": "搜索全部模型",
"Source": "来源",

View file

@ -18,3 +18,4 @@ export * from "./routing";
export * from "./virtual-models";
export * from "./extensions";
export * from "./providers";
export type { RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig } from "../../../../shared/app";

View file

@ -390,26 +390,36 @@ export const localAgentProviderIconUrls: Record<LocalAgentProviderKind, string>
};
export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] {
const providerModels: string[] = [];
const displayNames: Record<string, string> = {};
for (const provider of config.Providers) {
for (const model of mergeProviderModelLists(provider.models)) {
providerModels.push(model);
const displayName = provider.modelDisplayNames?.[model]?.trim();
if (displayName && !displayNames[model]) {
displayNames[model] = displayName;
}
const rows: ModelCatalogItem[] = [];
config.Providers.forEach((provider, providerIndex) => {
const providerName = provider.name?.trim();
if (!providerName) {
return;
}
}
for (const model of mergeProviderModelLists(provider.models)) {
const description = provider.modelDescriptions?.[model]?.trim();
const displayName = provider.modelDisplayNames?.[model]?.trim();
rows.push({
...(description ? { description } : {}),
...(displayName ? { displayName } : {}),
key: `provider-model:${providerIndex}:${providerName}:${model}`,
model,
providerIndex,
providerName
});
}
});
const virtualModels = (config.virtualModelProfiles ?? [])
.filter(virtualModelIsCatalogVisible)
.flatMap(virtualModelCatalogNames);
return uniqueStrings([...providerModels, ...virtualModels]).map((model, index) => ({
...(displayNames[model] ? { displayName: displayNames[model] } : {}),
key: `model:${index}:${model}`,
model
}));
return [
...rows,
...uniqueStrings(virtualModels).map((model, index) => ({
key: `virtual-model:${index}:${model}`,
model
}))
];
}
export function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean {
@ -462,7 +472,10 @@ export function modelCatalogItemMatchesQuery(row: ModelCatalogItem, query: strin
return true;
}
return row.model.toLowerCase().includes(query) || (row.displayName ?? "").toLowerCase().includes(query);
return row.model.toLowerCase().includes(query) ||
(row.providerName ?? "").toLowerCase().includes(query) ||
(row.displayName ?? "").toLowerCase().includes(query) ||
(row.description ?? "").toLowerCase().includes(query);
}
export function createRouteModelOptions(providers: GatewayProviderConfig[]): Array<{ label: string; value: string }> {
@ -611,7 +624,7 @@ export function createRoutingRuleDraft(config?: AppConfig): AddRoutingRuleDraft
rewriteValue: rewrite.value,
rewrites: [rewrite],
target: "",
threshold: String(config?.Router.longContextThreshold || 200000),
threshold: "200000",
type: "condition"
};
}
@ -635,7 +648,7 @@ export function createRoutingRuleDraftFromRule(rule: RouterRule, config?: AppCon
rewriteValue: firstRewrite.value,
rewrites: rewrites.length ? rewrites : [firstRewrite],
target: rule.target ?? "",
threshold: String(rule.threshold ?? config?.Router.longContextThreshold ?? 200000),
threshold: String(rule.threshold ?? 200000),
type: "condition"
};
}
@ -843,6 +856,7 @@ export function createProviderDraftFromDeepLinkPayload(
baseUrl,
credentials: [],
icon: payload.icon?.trim() || "",
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), payload.modelDisplayNames),
models
@ -905,6 +919,7 @@ export function createProviderConfigFromDeepLink(
api_key: apiKey,
capabilities: capabilities.length > 0 ? capabilities : undefined,
icon: payload.icon?.trim() || undefined,
modelDescriptions: modelDescriptionsForModels(payload.modelDescriptions, models),
modelDisplayNames: modelDisplayNamesForModels(mergeModelDisplayNames(payload.modelDisplayNames, probe?.modelDisplayNames), models),
models,
name,
@ -930,6 +945,7 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
baseUrl: "",
credentials: [],
icon: "",
modelDescriptions: undefined,
modelDisplayNames: undefined,
modelSearch: "",
modelsText: "",
@ -953,6 +969,7 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
baseUrl,
credentials: (provider.credentials ?? []).map(providerCredentialDraftFromConfig),
icon: provider.icon ?? "",
modelDescriptions: modelDescriptionsForModels(provider.modelDescriptions, provider.models),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), provider.modelDisplayNames),
provider.models
@ -1502,11 +1519,13 @@ export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, prob
return accountKeySafetyIssue.message;
}
const modelDescriptions = modelDescriptionsForModels(draft.modelDescriptions, models);
const modelDisplayNames = modelDisplayNamesForModels(draft.modelDisplayNames, models);
const payload: ProviderDeepLinkPayload = {
...(account ? { account } : {}),
baseUrl,
...(draft.icon.trim() ? { icon: draft.icon.trim() } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
name: providerName,
@ -1869,6 +1888,17 @@ export function modelDisplayNamesForModels(
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function modelDescriptionsForModels(
value: Record<string, string> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, rawDescription]) => [rawModel.trim(), rawDescription.trim()] as const)
.filter(([model, description]) => model && description && modelIds.has(model));
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function providerModelDisplayName(provider: GatewayProviderConfig, model: string): string {
return provider.modelDisplayNames?.[model]?.trim() || model;
}

View file

@ -205,6 +205,8 @@ import type {
RequestLogListFilter,
RequestLogPage,
RequestLogStatusFilter,
RouterBuiltInAgentRuleId,
RouterBuiltInRulesConfig,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -376,7 +378,7 @@ import type { MotionSafeDivAttributes } from "./motion";
import { positiveInteger } from "./api-keys";
import { isPlainRecord, stringValue, uniqueStrings } from "./common";
import { sanitizeConfigId } from "./extensions";
import { formatRouterRuleCondition, formatRouterRuleTarget, numberValue, routerRuleTypeLabel } from "./providers";
import { formatRouterRuleCondition, formatRouterRuleTarget, routerRuleTypeLabel } from "./providers";
import { clampNumber } from "./services";
import type { ClaudeDesignRouteRuleType, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, PluginRoutingConfigItem, RoutingRuleRow } from "./types";
@ -388,12 +390,30 @@ export function normalizeRouterConfig(value: Partial<RouterConfig> | undefined):
const rules = normalizeRouterRules((value as Record<string, unknown> | undefined)?.rules) ?? [];
return {
...router,
builtInRules: normalizeRouterBuiltInRules((value as Record<string, unknown> | undefined)?.builtInRules),
fallback: normalizeRouterFallbackConfig((value as Record<string, unknown> | undefined)?.fallback),
longContextThreshold: Number(router.longContextThreshold) > 0 ? numberValue(String(router.longContextThreshold)) : fallbackConfig.Router.longContextThreshold,
rules
};
}
export function normalizeRouterBuiltInRules(value: unknown): RouterBuiltInRulesConfig {
const record = isPlainRecord(value) ? value : {};
return {
"claude-code": normalizeRouterBuiltInAgentRule(record["claude-code"] ?? record.claudeCode ?? record.claude),
codex: normalizeRouterBuiltInAgentRule(record.codex)
};
}
function normalizeRouterBuiltInAgentRule(value: unknown): { enabled: boolean } {
if (typeof value === "boolean") {
return { enabled: value };
}
const record = isPlainRecord(value) ? value : {};
return {
enabled: typeof record.enabled === "boolean" ? record.enabled : true
};
}
export function normalizeRouterFallbackConfig(value: Partial<RouterFallbackConfig> | unknown): RouterFallbackConfig {
const record = isPlainRecord(value) ? value : {};
const mode = parseRouterFallbackMode(record.mode) ?? fallbackConfig.Router.fallback.mode;
@ -806,19 +826,74 @@ export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDra
}
export function buildRoutingRuleRows(config: AppConfig): RoutingRuleRow[] {
return config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}));
return [
...buildBuiltInAgentRoutingRows(config),
...config.Router.rules.map((rule, index): RoutingRuleRow => ({
condition: formatRouterRuleCondition(rule),
enabled: rule.enabled,
index,
key: `router-${rule.id}-${index}`,
name: rule.name || "Unnamed",
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: rule.id,
sourceLabel: "Router",
target: formatRouterRuleTarget(rule),
typeLabel: routerRuleTypeLabel(rule.type)
}))
];
}
export function buildBuiltInAgentRoutingRows(config: AppConfig): RoutingRuleRow[] {
return routerBuiltInAgentRuleIds.map((agent): RoutingRuleRow => {
const target = routerBuiltInAgentRouteTarget(config, agent);
const profileEnabled = Boolean(routerBuiltInAgentProfile(config, agent));
return {
builtInAgent: agent,
condition: `request.header.user-agent contains ${routerBuiltInAgentUserAgentNeedle(agent)}`,
enabled: routerBuiltInAgentRuleIsActive(config, agent),
key: `builtin-agent-${agent}`,
name: routerBuiltInAgentRuleName(agent),
readonly: false,
ruleCount: config.Router.rules.length,
ruleId: `builtin-agent-${agent}`,
sourceLabel: "Built-in",
target: target ? `set request.body.model = ${target}` : "Profile model unset",
toggleDisabled: !profileEnabled || !target,
typeLabel: "Condition"
};
});
}
const routerBuiltInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"];
export function routerBuiltInAgentRuleIsActive(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return routerBuiltInAgentRulePreferenceEnabled(config, agent) &&
Boolean(routerBuiltInAgentProfile(config, agent)) &&
Boolean(routerBuiltInAgentRouteTarget(config, agent));
}
export function routerBuiltInAgentRulePreferenceEnabled(config: AppConfig, agent: RouterBuiltInAgentRuleId): boolean {
return config.Router.builtInRules?.[agent]?.enabled !== false;
}
export function routerBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId): ProfileConfig | undefined {
if (config.profile.enabled === false) {
return undefined;
}
return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent);
}
export function routerBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string {
return routerBuiltInAgentProfile(config, agent)?.model.trim() || config.Router.default?.trim() || "";
}
export function routerBuiltInAgentRuleName(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "Claude Code" : "Codex";
}
export function routerBuiltInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
}
export function buildPluginRoutingRows(plugin: AppConfig["plugins"][number], pluginIndex: number): RoutingRuleRow[] {

View file

@ -204,6 +204,7 @@ import type {
RequestLogListFilter,
RequestLogPage,
RequestLogStatusFilter,
RouterBuiltInAgentRuleId,
RouterConfig,
RouterFallbackConfig,
RouterFallbackMode,
@ -421,6 +422,7 @@ export type AddProviderDraft = {
baseUrl: string;
credentials: ProviderCredentialDraft[];
icon: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
modelSearch: string;
modelsText: string;
@ -694,9 +696,12 @@ export type ExtensionListItem = {
};
export type ModelCatalogItem = {
description?: string;
displayName?: string;
key: string;
model: string;
providerIndex?: number;
providerName?: string;
};
export type PluginInstallCandidate = {
@ -715,6 +720,7 @@ export type PluginSettingsDraft = {
};
export type RoutingRuleRow = {
builtInAgent?: RouterBuiltInAgentRuleId;
condition: string;
enabled: boolean;
index?: number;
@ -726,6 +732,7 @@ export type RoutingRuleRow = {
ruleId: string;
sourceLabel: string;
target: string;
toggleDisabled?: boolean;
typeLabel: string;
};

View file

@ -446,7 +446,7 @@ export function createVirtualModelDraftFromProfile(profile: VirtualModelProfileC
}
return {
baseModelMode: "fixed",
clientToolsPolicy: profile.execution?.clientToolsPolicy === "deny" ? "deny" : "allow",
clientToolsPolicy: "allow",
customMcpServer: customMcpServerDraft,
customToolName,
description: profile.description ?? "",
@ -752,7 +752,7 @@ export function virtualModelProfileFromDraft(
displayName,
enabled: draft.enabled,
execution: {
clientToolsPolicy: draft.clientToolsPolicy,
clientToolsPolicy: "allow",
...flags,
maxToolCalls: clampNumber(maxToolCalls || Math.max(tools.length, 1), 1, 50),
maxTurns: clampNumber(maxTurns || 6, 1, 50),

View file

@ -556,7 +556,7 @@
.network-table-scroll,
.network-detail,
.network-json-fullscreen,
.network-json-fullscreen-panel,
.network-json-fullscreen-body,
.network-pane-body {
background: var(--network-panel);
@ -564,13 +564,28 @@
}
.network-json-fullscreen {
align-items: center;
background: rgba(0, 0, 0, .82);
color: var(--network-text);
justify-content: center;
padding: clamp(14px, 4.4vw, 56px) clamp(14px, 4.4vw, 84px);
}
.network-json-fullscreen-panel {
border-color: var(--network-border);
border-radius: 24px;
box-shadow: 0 24px 72px rgba(0, 0, 0, .42);
height: 100%;
max-height: 100%;
max-width: 1760px;
min-width: 0;
width: 100%;
}
.network-json-fullscreen-header {
background: var(--network-panel-alt);
border-color: var(--network-border);
box-shadow: var(--network-shadow);
box-shadow: none;
}
.network-table-header,
@ -760,6 +775,19 @@
color: color-mix(in oklab, var(--network-active-bg) 70%, var(--network-text));
}
@media (max-width: 720px), (max-height: 520px) {
.network-json-fullscreen {
padding: 0;
}
.network-json-fullscreen-panel {
border-left: 0;
border-radius: 0;
border-right: 0;
max-width: none;
}
}
.onboarding-mascot-sprite {
aspect-ratio: 1 / 1;
background-position: 0 0;

View file

@ -2,12 +2,13 @@ import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import os from "node:os";
import path from "node:path";
import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import type { AppConfig, RouterBuiltInAgentRuleId, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
import { CONFIGDIR } from "../../main/constants";
type HeaderValue = string | string[] | undefined;
export type MutableRequestLike = {
builtInSubagentModel?: string;
body: Record<string, unknown>;
headers: Record<string, HeaderValue>;
log: Pick<Console, "debug" | "error" | "info" | "warn">;
@ -47,17 +48,22 @@ export class ClaudeCodeRouterPlugin {
url: string;
}): Promise<{ body: Record<string, unknown>; decision: ClaudeCodeRouteDecision }> {
const body = cloneRecord(input.body);
const sessionId = resolveSessionId(body, input.headers);
const tokenCount = calculateTokenCount(body.messages, body.system, body.tools);
const request: MutableRequestLike = {
body,
headers: input.headers,
log: console,
method: input.method,
sessionId,
tokenCount,
url: input.url
};
if (builtInAgentRouteMatches(request, this.config, "claude-code")) {
injectClaudeCodeAgentToolDescription(body, this.config);
removeClaudeCodeBillingSystemHeader(body);
request.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(body);
}
const sessionId = resolveSessionId(body, input.headers);
const tokenCount = calculateTokenCount(body.messages, body.system, body.tools);
request.sessionId = sessionId;
request.tokenCount = tokenCount;
const customModel = await this.resolveCustomRoute(request);
const configuredDecision = resolveConfiguredRouteDecision(request, this.config);
@ -179,6 +185,11 @@ function resolveConfiguredRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision {
const builtInSubagentDecision = resolveBuiltInClaudeCodeSubagentRouteDecision(request, config);
if (builtInSubagentDecision) {
return builtInSubagentDecision;
}
const requestedModel = readString(request.body.model);
const explicitModel = normalizeRouteSelector(requestedModel);
if (explicitModel && isKnownInlineRoute(explicitModel, config)) {
@ -186,6 +197,11 @@ function resolveConfiguredRouteDecision(
}
const router = config.Router;
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config);
if (builtInDecision) {
return builtInDecision;
}
const rules = router.rules ?? [];
for (const rule of rules) {
const decision = resolveRouterRule(rule, request, router);
@ -197,6 +213,374 @@ function resolveConfiguredRouteDecision(
return { fallback: router.fallback, model: normalizeRouteSelector(router.default) ?? explicitModel, reason: "default" };
}
function resolveBuiltInClaudeCodeSubagentRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision | undefined {
if (!builtInAgentRouteMatches(request, config, "claude-code")) {
return undefined;
}
const target = normalizeRouteSelector(request.builtInSubagentModel);
if (!target) {
return undefined;
}
return {
fallback: config.Router.fallback,
model: target,
reason: "builtin:claude-code-subagent",
rewrite: {
key: "request.body.model",
operation: "set",
value: target
}
};
}
function resolveBuiltInAgentRouteDecision(
request: MutableRequestLike,
config: AppConfig
): ConfiguredRouteDecision | undefined {
for (const agent of builtInAgentRuleIds) {
if (!builtInAgentRouteMatches(request, config, agent)) {
continue;
}
const target = resolveBuiltInAgentRouteTarget(config, agent);
if (!target) {
continue;
}
return {
fallback: config.Router.fallback,
model: target,
reason: `builtin:${agent}`,
rewrite: {
key: "request.body.model",
operation: "set",
value: target
}
};
}
return undefined;
}
const builtInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"];
function builtInAgentRouteMatches(
request: MutableRequestLike,
config: AppConfig,
agent: RouterBuiltInAgentRuleId
): boolean {
if (config.Router.builtInRules?.[agent]?.enabled === false) {
return false;
}
if (!resolveBuiltInAgentProfile(config, agent)) {
return false;
}
const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? "";
return userAgent.includes(builtInAgentUserAgentNeedle(agent));
}
function resolveBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId) {
if (config.profile.enabled === false) {
return undefined;
}
return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent);
}
function resolveBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined {
return normalizeRouteSelector(resolveBuiltInAgentProfile(config, agent)?.model) ??
normalizeRouteSelector(config.Router.default);
}
function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string {
return agent === "claude-code" ? "claude" : "codex";
}
const ccrSubagentModelOpenTag = "<CCR-SUBAGENT-MODEL>";
const ccrSubagentModelCloseTag = "</CCR-SUBAGENT-MODEL>";
const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`;
const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header";
const ccrSubagentToolModelInstruction =
`CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` +
`${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` +
`CCR consumes the tag, removes it from the prompt, and routes the spawned agent request to that model. ` +
`Do not omit the tag, do not put it in description or subagent_type, and do not rely on the tool model enum for CCR-specific models. ` +
`中文要求调用该工具时prompt 参数第一行必须写入 ${ccrSubagentModelTagExample},并将 Provider/model 替换成最适合的 CCR 模型。`;
const ccrWorkflowSubagentModelInstruction =
`CCR workflow subagent routing is enabled. When this workflow creates Agent/Task subagents, each spawned agent prompt MUST start with ` +
`${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` +
`Put the tag inside the Agent/Task prompt created by the workflow, not in the workflow description or subagent_type. ` +
`CCR consumes the tag from the spawned agent request, removes it, and routes that request to the selected model. ` +
`Do not rely on the Agent/Task model enum for CCR-specific models. ` +
`中文要求Workflow 中创建 Agent/Task 时,每个被创建 agent 的 prompt 第一行必须写入 ${ccrSubagentModelTagExample},并替换成最适合的 CCR 模型。`;
const ccrSubagentPromptFieldInstruction =
`CCR subagent routing is enabled. This prompt string MUST start with ${ccrSubagentModelTagExample} on its own first line, ` +
`with Provider/model replaced by one configured CCR gateway model. Put the subagent task after that line; CCR removes the tag before the subagent runs. ` +
`中文要求:这个 prompt 字符串第一行必须是替换后的模型标签,后面再写 subagent 任务正文。`;
type ClaudeCodeSubagentToolKind = "subagent" | "workflow";
const claudeCodeAgentToolNames = new Set(["agent", "task"]);
const claudeCodeWorkflowToolNames = new Set(["workflow"]);
function injectClaudeCodeAgentToolDescription(body: Record<string, unknown>, config: AppConfig): void {
if (!Array.isArray(body.tools)) {
return;
}
const instructions = claudeCodeAgentToolInstructions(config);
if (!instructions) {
return;
}
for (const tool of body.tools) {
if (!isRecord(tool)) {
continue;
}
const toolKind = claudeCodeSubagentToolKind(tool);
if (!toolKind) {
continue;
}
appendToolDescriptionInstruction(tool, toolKind === "workflow" ? instructions.workflow : instructions.tool);
if (toolKind === "subagent") {
appendPromptSchemaDescriptionInstruction(tool, instructions.prompt);
}
}
}
function claudeCodeSubagentToolKind(tool: Record<string, unknown>): ClaudeCodeSubagentToolKind | undefined {
const functionSpec = isRecord(tool.function) ? tool.function : undefined;
const name = readString(tool.name)?.toLowerCase() ?? readString(functionSpec?.name)?.toLowerCase();
if (!name) {
return undefined;
}
if (claudeCodeAgentToolNames.has(name)) {
return "subagent";
}
if (claudeCodeWorkflowToolNames.has(name)) {
return "workflow";
}
return undefined;
}
function appendToolDescriptionInstruction(tool: Record<string, unknown>, instruction: string): void {
if (isRecord(tool.function)) {
tool.function.description = appendDescriptionInstruction(readOptionalString(tool.function.description), instruction);
return;
}
tool.description = appendDescriptionInstruction(readOptionalString(tool.description), instruction);
}
function appendPromptSchemaDescriptionInstruction(tool: Record<string, unknown>, instruction: string): void {
const functionSpec = isRecord(tool.function) ? tool.function : undefined;
const schema = isRecord(tool.input_schema)
? tool.input_schema
: isRecord(tool.inputSchema)
? tool.inputSchema
: isRecord(functionSpec?.parameters)
? functionSpec.parameters
: undefined;
const properties = isRecord(schema?.properties) ? schema.properties : undefined;
const prompt = isRecord(properties?.prompt) ? properties.prompt : undefined;
if (!prompt) {
return;
}
prompt.description = appendDescriptionInstruction(readOptionalString(prompt.description), instruction);
}
function appendDescriptionInstruction(description: string | undefined, instruction: string): string {
const existing = description?.trim() ?? "";
if (existing.includes(ccrSubagentModelOpenTag)) {
return existing;
}
return existing ? `${existing}\n\n${instruction}` : instruction;
}
function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined {
const modelRows = configuredSubagentModelDescriptionRows(config);
if (modelRows.length === 0) {
return undefined;
}
const modelList = [
"Configured CCR gateway models:",
...modelRows
].join("\n");
return {
prompt: [
ccrSubagentPromptFieldInstruction,
"",
modelList
].join("\n"),
tool: [
ccrSubagentToolModelInstruction,
"",
modelList
].join("\n"),
workflow: [
ccrWorkflowSubagentModelInstruction,
"",
modelList
].join("\n")
};
}
function configuredSubagentModelDescriptionRows(config: AppConfig): string[] {
const rows: string[] = [];
const seen = new Set<string>();
for (const provider of config.Providers) {
const providerName = provider.name?.trim();
if (!providerName || !Array.isArray(provider.models)) {
continue;
}
for (const rawModel of provider.models) {
const model = rawModel.trim();
const description = provider.modelDescriptions?.[model]?.trim();
if (!model || !description) {
continue;
}
const selector = `${providerName}/${model}`;
const key = selector.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const displayName = provider.modelDisplayNames?.[model]?.trim();
const label = displayName && displayName !== model ? `${selector} (${displayName})` : selector;
rows.push(`- ${label}: ${singleLineText(description, 320)}`);
}
}
return rows;
}
function removeClaudeCodeBillingSystemHeader(body: Record<string, unknown>): void {
const system = body.system;
if (!Array.isArray(system) || system.length === 0) {
return;
}
const firstBlock = system[0];
const firstText = typeof firstBlock === "string"
? firstBlock
: isRecord(firstBlock) && firstBlock.type === "text" && typeof firstBlock.text === "string"
? firstBlock.text
: undefined;
if (!firstText?.startsWith(claudeCodeBillingSystemHeaderPrefix)) {
return;
}
system.shift();
if (system.length === 0) {
delete body.system;
}
}
function extractAndRemoveClaudeCodeSubagentModelTag(body: Record<string, unknown>): string | undefined {
const systemModel = extractAndRemoveSystemSubagentModelTag(body);
if (systemModel) {
return systemModel;
}
return extractAndRemoveMessageSubagentModelTag(body);
}
function extractAndRemoveSystemSubagentModelTag(body: Record<string, unknown>): string | undefined {
const system = body.system;
if (typeof system === "string") {
return extractAndRemoveSubagentModelTagFromText(system, (text) => {
body.system = text;
});
}
if (!Array.isArray(system)) {
return undefined;
}
for (let index = 0; index < system.length; index += 1) {
const block = system[index];
const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => {
if (typeof block === "string") {
system[index] = text;
} else if (isRecord(block)) {
block.text = text;
}
});
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveMessageSubagentModelTag(body: Record<string, unknown>): string | undefined {
if (!Array.isArray(body.messages)) {
return undefined;
}
const limit = Math.min(body.messages.length, 2);
for (let index = 0; index < limit; index += 1) {
const message = body.messages[index];
if (!isRecord(message) || message.role !== "user") {
continue;
}
const model = extractAndRemoveSubagentModelTagFromMessage(message);
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveSubagentModelTagFromMessage(message: Record<string, unknown>): string | undefined {
if (typeof message.content === "string") {
return extractAndRemoveSubagentModelTagFromText(message.content, (text) => {
message.content = text;
});
}
if (!Array.isArray(message.content)) {
return undefined;
}
const content = message.content;
for (let index = 0; index < content.length; index += 1) {
const block = content[index];
const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => {
if (typeof block === "string") {
content[index] = text;
} else if (isRecord(block)) {
block.text = text;
}
});
if (model) {
return model;
}
}
return undefined;
}
function extractAndRemoveSubagentModelTagFromContentBlock(
block: unknown,
replace: (text: string) => void
): string | undefined {
if (typeof block === "string") {
return extractAndRemoveSubagentModelTagFromText(block, replace);
}
if (!isRecord(block) || typeof block.text !== "string") {
return undefined;
}
return extractAndRemoveSubagentModelTagFromText(block.text, replace);
}
function extractAndRemoveSubagentModelTagFromText(
text: string,
replace: (text: string) => void
): string | undefined {
const openIndex = text.indexOf(ccrSubagentModelOpenTag);
if (openIndex < 0) {
return undefined;
}
const modelStart = openIndex + ccrSubagentModelOpenTag.length;
const closeIndex = text.indexOf(ccrSubagentModelCloseTag, modelStart);
if (closeIndex < 0) {
return undefined;
}
const model = normalizeRouteSelector(text.slice(modelStart, closeIndex));
if (!model) {
return undefined;
}
const nextText = `${text.slice(0, openIndex)}${text.slice(closeIndex + ccrSubagentModelCloseTag.length)}`;
replace(nextText);
return model;
}
function resolveRouterRule(
rule: RouterRule,
request: MutableRequestLike,
@ -617,6 +1001,14 @@ function conditionComparableText(value: unknown): string | undefined {
return String(value);
}
function singleLineText(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
}
function routerRuleReason(rule: RouterRule): string {
if (rule.id.startsWith("legacy-")) {
return rule.id.replace(/^legacy-/, "");
@ -753,3 +1145,7 @@ function readHeader(value: HeaderValue): string | undefined {
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

View file

@ -207,6 +207,7 @@ const gatewayRuntimeMarkerFile = "gateway-runtime.json";
const rawTraceSyncHeader = "x-ccr-raw-trace-token";
let warnedMissingCursorOpenAICompatContext = false;
const rawTraceSyncPath = "/__ccr/raw-trace-sync";
const gatewayEntryOverrideEnv = "CCR_GATEWAY_ENTRY";
const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"];
const apiKeyLimitCounters = new Map<string, ApiKeyWindowCounter>();
const providerCredentialCooldowns = new Map<string, { reason: string; until: number }>();
@ -882,10 +883,10 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
...pluginService.getCoreProviderPlugins()
]);
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
...(config.virtualModelProfiles ?? []),
...pluginService.getVirtualModelProfiles()
]))), config);
])), config);
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint);
const providers = [
@ -1399,25 +1400,6 @@ function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[]
});
}
function withOptimisticVirtualModelStreams(profiles: unknown[]): unknown[] {
return profiles.map((profile) => {
if (!isRecord(profile) || profile.enabled === false) {
return profile;
}
const execution = isRecord(profile.execution) ? profile.execution : {};
if (execution.streamMode === "optimistic") {
return profile;
}
return {
...profile,
execution: {
...execution,
streamMode: "optimistic"
}
};
});
}
function fusionModelSelector(model: string): string {
const normalized = fusionModelNameFromSelector(model);
return normalized ? `${fusionModelProviderName}/${normalized}` : "";
@ -2642,6 +2624,15 @@ function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undef
}
function resolveGatewayEntry(): string {
const override = process.env[gatewayEntryOverrideEnv]?.trim();
if (override) {
const entry = pathResolve(override);
if (!existsSync(entry)) {
throw new Error(`${gatewayEntryOverrideEnv} points to a missing gateway entry: ${entry}`);
}
return entry;
}
for (const packageName of gatewayPackageCandidates) {
try {
return requireFromHere.resolve(packageName);
@ -2794,27 +2785,43 @@ function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[
function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] {
const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : [];
const normalized: GatewayProviderCapability[] = [];
const seen = new Set<string>();
const byProtocol = new Map<GatewayProviderProtocol, GatewayProviderCapability>();
for (const capability of capabilities) {
const type = normalizeProviderProtocol(capability.type);
const baseUrl = capability.baseUrl?.trim();
if (!type || !baseUrl) {
continue;
}
const key = `${type}\n${baseUrl}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
const item = {
...capability,
baseUrl,
type
});
};
const existing = byProtocol.get(type);
if (!existing || providerCapabilityPriority(item) < providerCapabilityPriority(existing)) {
byProtocol.set(type, item);
}
}
for (const capability of capabilities) {
const type = normalizeProviderProtocol(capability.type);
const selected = type ? byProtocol.get(type) : undefined;
if (selected && !normalized.includes(selected)) {
normalized.push(selected);
}
}
return normalized;
}
function providerCapabilityPriority(capability: GatewayProviderCapability): number {
if (capability.source === "preset") {
return 0;
}
if (capability.source === "detected") {
return 2;
}
return 1;
}
function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string {
return `${providerRuntimeId(provider)}::${protocol}`;
}

View file

@ -129,6 +129,7 @@ export type GatewayProviderConfig = {
extraHeaders?: unknown;
icon?: string;
id?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
@ -273,6 +274,7 @@ export type ProviderDeepLinkPayload = {
apiKey?: string;
baseUrl: string;
icon?: string;
modelDescriptions?: Record<string, string>;
modelDisplayNames?: Record<string, string>;
models: string[];
name?: string;
@ -520,16 +522,19 @@ export type RouterFallbackConfig = {
retryCount: number;
};
export type RouterBuiltInAgentRuleId = "claude-code" | "codex";
export type RouterBuiltInAgentRuleConfig = {
enabled: boolean;
};
export type RouterBuiltInRulesConfig = Record<RouterBuiltInAgentRuleId, RouterBuiltInAgentRuleConfig>;
export type RouterConfig = {
background?: string;
builtInRules: RouterBuiltInRulesConfig;
default?: string;
fallback: RouterFallbackConfig;
image?: string;
longContext?: string;
longContextThreshold: number;
rules: RouterRule[];
think?: string;
webSearch?: string;
};
export type GatewayRuntimeConfig = {

View file

@ -20,6 +20,7 @@ const maxIconLength = 8_192;
const maxManifestUrlLength = 2_048;
const maxSourceLength = 2_048;
const maxModelLength = 256;
const maxModelDescriptionLength = 1_000;
const maxModels = 300;
const providerProtocols = new Set<GatewayProviderProtocol>([
@ -147,6 +148,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
@ -160,6 +162,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
@ -218,6 +221,7 @@ function parseProviderPayloadFields(
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
@ -233,6 +237,7 @@ function parseProviderPayloadFields(
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDescriptions ? { modelDescriptions } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
@ -563,6 +568,43 @@ function readDeepLinkModelDisplayNames(
return Object.keys(displayNames).length > 0 ? displayNames : undefined;
}
function readDeepLinkModelDescriptions(
params: URLSearchParams,
payload: Record<string, unknown> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const descriptions: Record<string, string> = {};
const addDescription = (rawModel: unknown, rawDescription: unknown) => {
const model = typeof rawModel === "string" ? rawModel.trim() : "";
const description = typeof rawDescription === "string" ? rawDescription.trim() : "";
if (!model || !description || !modelIds.has(model)) {
return;
}
if (description.length > maxModelDescriptionLength) {
throw new Error("Model description is too long.");
}
descriptions[model] = description;
};
const explicit = parseJsonValueParam(params, payload, ["modelDescriptions", "model_descriptions"]);
if (isRecord(explicit)) {
for (const [model, description] of Object.entries(explicit)) {
addDescription(model, description);
}
}
const payloadModelList = Array.isArray(payload?.models) ? payload.models : [];
for (const item of payloadModelList) {
if (!isRecord(item)) {
continue;
}
addDescription(readPayloadModelId(item), firstPayloadString(item, ["description", "desc", "summary"]));
}
return Object.keys(descriptions).length > 0 ? descriptions : undefined;
}
function readPayloadModelId(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;

View file

@ -33,12 +33,19 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
PORT: 3456,
Providers: [],
Router: {
builtInRules: {
"claude-code": {
enabled: true
},
codex: {
enabled: true
}
},
fallback: {
mode: "off",
models: [],
retryCount: 1
},
longContextThreshold: 200000,
rules: []
},
agent: {

View file

@ -16,7 +16,7 @@ function base64UrlJson(value) {
.replace(/=+$/, "");
}
test("parseProviderDeepLinkPayload reads payload JSON, models, display names, and usage account mapping", () => {
test("parseProviderDeepLinkPayload reads payload JSON, models, descriptions, display names, and usage account mapping", () => {
const payload = {
account: {
connectors: {
@ -34,8 +34,11 @@ test("parseProviderDeepLinkPayload reads payload JSON, models, display names, an
model_display_names: {
"model-a": "Model A"
},
model_descriptions: {
"model-a": "Fast general-purpose model."
},
models: [
{ displayName: "Model B", id: "model-b" },
{ description: "Best at coding tasks.", displayName: "Model B", id: "model-b" },
"model-a,model-c"
],
name: "Example AI",
@ -54,6 +57,10 @@ test("parseProviderDeepLinkPayload reads payload JSON, models, display names, an
"model-a": "Model A",
"model-b": "Model B"
});
assert.deepEqual(parsed.modelDescriptions, {
"model-a": "Fast general-purpose model.",
"model-b": "Best at coding tasks."
});
assert.equal(parsed.account?.enabled, true);
assert.equal(parsed.account?.refreshIntervalMs, 60000);
assert.equal(parsed.account?.connectors?.[0]?.type, "http-json");

View file

@ -0,0 +1,492 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ClaudeCodeRouterPlugin } from "../../src/server/gateway/claude-code-router-plugin.ts";
function createRouterPlugin(options = {}) {
const agent = options.agent ?? "claude-code";
return new ClaudeCodeRouterPlugin({
CUSTOM_ROUTER_PATH: "",
Providers: [
{
modelDescriptions: options.modelDescriptions,
modelDisplayNames: options.modelDisplayNames,
models: ["claude-sonnet", "gpt-5-codex"],
name: "Provider",
type: "anthropic_messages"
}
],
Router: {
builtInRules: {
"claude-code": { enabled: options.claudeCodeRuleEnabled ?? true },
codex: { enabled: options.codexRuleEnabled ?? true }
},
default: options.defaultModel ?? "",
fallback: { mode: "off", models: [], retryCount: 1 },
rules: []
},
profile: {
enabled: options.profileRuntimeEnabled ?? true,
profiles: [
{
agent,
enabled: options.profileEnabled ?? true,
id: `${agent}-profile`,
model: options.profileModel ?? "",
name: agent,
scope: "global"
}
]
}
});
}
test("built-in Claude Code route matches user-agent case-insensitively", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default"
},
headers: {
"user-agent": "claude-code/1.0"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-sonnet");
assert.equal(result.decision.model, "Provider/claude-sonnet");
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Codex route can use Router.default when profile model is unset", async () => {
const plugin = createRouterPlugin({
agent: "codex",
defaultModel: "Provider/gpt-5-codex"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "gpt-5"
},
headers: {
"user-agent": "openai-codex test"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/gpt-5-codex");
assert.equal(result.decision.reason, "builtin:codex");
});
test("built-in agent route stays off after the user disables it", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "claude-default");
assert.equal(result.decision.reason, "default");
});
test("built-in Claude Code route injects subagent model instructions into Agent and Task tools", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation.",
"gpt-5-codex": "Use for long refactors and repository-scale reasoning."
},
modelDisplayNames: {
"claude-sonnet": "Claude Sonnet"
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Agent"
},
{
description: "Start a task.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Task"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
for (const tool of result.body.tools) {
assert.match(tool.description, /<CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.description, /MUST start/);
assert.match(tool.description, /Provider\/claude-sonnet \(Claude Sonnet\): Balanced coding model/);
assert.match(tool.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.match(tool.input_schema.properties.prompt.description, /MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.input_schema.properties.prompt.description, /Provider\/claude-sonnet \(Claude Sonnet\): Balanced coding model/);
assert.match(tool.input_schema.properties.prompt.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.doesNotMatch(tool.input_schema.properties.prompt.description, /optionally include/);
}
});
test("built-in Claude Code route injects workflow subagent model instructions into the Workflow tool", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation.",
"gpt-5-codex": "Use for long refactors and repository-scale reasoning."
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Run a workflow.",
input_schema: {
properties: {
script: { description: "Workflow script.", type: "string" }
},
type: "object"
},
name: "Workflow"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.match(tool.description, /CCR workflow subagent routing is enabled/);
assert.match(tool.description, /Agent\/Task subagents/);
assert.match(tool.description, /each spawned agent prompt MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.description, /Provider\/claude-sonnet: Balanced coding model/);
assert.match(tool.description, /Provider\/gpt-5-codex: Use for long refactors/);
assert.equal(tool.input_schema.properties.script.description, "Workflow script.");
});
test("built-in Claude Code route injects subagent model instructions into function-style Agent tools", async () => {
const plugin = createRouterPlugin({
modelDescriptions: {
"claude-sonnet": "Balanced coding model for everyday implementation."
},
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
function: {
description: "Start a subagent.",
name: "Agent",
parameters: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
}
},
type: "function"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.match(tool.function.description, /<CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
assert.match(tool.function.parameters.properties.prompt.description, /MUST start with <CCR-SUBAGENT-MODEL>Provider\/model<\/CCR-SUBAGENT-MODEL>/);
});
test("built-in Claude Code route skips subagent instruction injection when no model has a description", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Agent"
},
{
description: "Run a workflow.",
input_schema: {
properties: {
script: { description: "Workflow script.", type: "string" }
},
type: "object"
},
name: "Workflow"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const agentTool = result.body.tools[0];
const workflowTool = result.body.tools[1];
assert.equal(agentTool.description, "Start a subagent.");
assert.equal(agentTool.input_schema.properties.prompt.description, "Task prompt.");
assert.equal(workflowTool.description, "Run a workflow.");
assert.equal(workflowTool.input_schema.properties.script.description, "Workflow script.");
});
test("disabled built-in Claude Code route does not inject Agent tool instructions", async () => {
const plugin = createRouterPlugin({
claudeCodeRuleEnabled: false,
profileModel: "Provider/claude-sonnet"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
tools: [
{
description: "Start a subagent.",
input_schema: {
properties: {
prompt: { description: "Task prompt.", type: "string" }
},
type: "object"
},
name: "Task"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
const tool = result.body.tools[0];
assert.equal(tool.description, "Start a subagent.");
assert.equal(tool.input_schema.properties.prompt.description, "Task prompt.");
});
test("built-in Claude Code subagent route uses model tag from system", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> for this subagent."
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-opus");
assert.equal(result.body.system, "Use for this subagent.");
assert.equal(result.decision.model, "Provider/claude-opus");
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code route removes the first billing system block before subagent tag extraction", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: [
{
text: "x-anthropic-billing-header: {\"cc_is_subagent\":true}",
type: "text"
},
{
text: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> for this subagent.",
type: "text"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-opus");
assert.deepEqual(result.body.system, [
{
text: "Use for this subagent.",
type: "text"
}
]);
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code route keeps a string billing system prompt unchanged", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: "x-anthropic-billing-header: {\"cc_is_subagent\":true}"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.system, "x-anthropic-billing-header: {\"cc_is_subagent\":true}");
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("built-in Claude Code route removes only the first billing system array item", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [],
model: "claude-default",
system: [
{
text: "x-anthropic-billing-header: {\"cc_is_subagent\":true}",
type: "text"
}
]
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal("system" in result.body, false);
assert.equal(result.decision.reason, "builtin:claude-code");
});
test("non-Claude-Code routes keep billing system prompts unchanged", async () => {
const plugin = createRouterPlugin({
agent: "codex",
defaultModel: "Provider/gpt-5-codex"
});
const result = await plugin.routeRequest({
body: {
messages: [],
model: "gpt-5",
system: "x-anthropic-billing-header: {\"cc_is_subagent\":true}"
},
headers: {
"user-agent": "openai-codex test"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.system, "x-anthropic-billing-header: {\"cc_is_subagent\":true}");
assert.equal(result.decision.reason, "builtin:codex");
});
test("built-in Claude Code subagent route scans only the first two messages for tags", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [
{ content: "first", role: "user" },
{
content: [
{
text: "second <CCR-SUBAGENT-MODEL>Provider/claude-haiku</CCR-SUBAGENT-MODEL>",
type: "text"
}
],
role: "user"
},
{ content: "third <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>", role: "user" }
],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-haiku");
assert.equal(result.body.messages[1].content[0].text, "second ");
assert.match(result.body.messages[2].content, /Provider\/claude-opus/);
assert.equal(result.decision.reason, "builtin:claude-code-subagent");
});
test("built-in Claude Code subagent route ignores tags outside the first two messages", async () => {
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
const result = await plugin.routeRequest({
body: {
messages: [
{ content: "first", role: "user" },
{ content: "assistant response", role: "assistant" },
{ content: "third <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>", role: "user" }
],
model: "claude-default"
},
headers: {
"user-agent": "Claude Code"
},
method: "POST",
url: "/v1/messages"
});
assert.equal(result.body.model, "Provider/claude-sonnet");
assert.match(result.body.messages[2].content, /Provider\/claude-opus/);
assert.equal(result.decision.reason, "builtin:claude-code");
});

View file

@ -3,6 +3,7 @@ import test from "node:test";
import { createDefaultAppConfig } from "../../src/shared/default-config.ts";
import {
createVirtualModelDraft,
createVirtualModelDraftFromProfile,
validateVirtualModelDraft,
virtualModelProfileFromDraft
} from "../../src/renderer/pages/home/shared/virtual-models.ts";
@ -32,11 +33,30 @@ test("Fusion draft saves multiple selected tools into one profile", () => {
assert.equal(profile.execution.matchMultimodal, true);
assert.equal(profile.execution.matchWebSearch, true);
assert.equal(profile.execution.maxToolCalls, 8);
assert.equal(profile.execution.clientToolsPolicy, "allow");
assert.equal(profile.execution.streamMode, "optimistic");
assert.equal(metadataString(profile.metadata, "fusionVision", "toolName"), "vision_understand_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionWebSearch", "toolName"), "web_search_fusion_plus");
assert.equal(metadataString(profile.metadata, "fusionTool", "mcpServerName"), "customer-tools");
});
test("Fusion default editing keeps client tools allowed", () => {
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-generated.json" });
const draft = createVirtualModelDraft(config);
draft.exactAliasesText = "fusion-default-tools";
draft.fixedModel = "provider/base-model";
draft.visionModel = "provider/vision-model";
const profile = virtualModelProfileFromDraft(draft, [], undefined);
profile.execution.clientToolsPolicy = "deny";
const editDraft = createVirtualModelDraftFromProfile(profile, config);
assert.equal(editDraft.clientToolsPolicy, "allow");
const savedProfile = virtualModelProfileFromDraft(editDraft, [], undefined);
assert.equal(savedProfile.execution.clientToolsPolicy, "allow");
});
function metadataString(metadata: Record<string, unknown> | undefined, key: string, field: string): string | undefined {
const value = metadata?.[key];
if (!value || typeof value !== "object") {