mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor router config and provider handling
This commit is contained in:
parent
73bcf2e6e6
commit
56c1b666c6
20 changed files with 1625 additions and 176 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "来源",
|
||||
|
|
|
|||
|
|
@ -18,3 +18,4 @@ export * from "./routing";
|
|||
export * from "./virtual-models";
|
||||
export * from "./extensions";
|
||||
export * from "./providers";
|
||||
export type { RouterBuiltInAgentRuleId, RouterBuiltInRulesConfig } from "../../../../shared/app";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
492
tests/main/router-builtins.test.mjs
Normal file
492
tests/main/router-builtins.test.mjs
Normal 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");
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue