Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
type ImageHandling,
type ModelWindowRow,
} from "./model-windows";
import { clampAggregateRoutePriority, normalizeAggregateRoutes, validateAggregateRoutes } from "./aggregate-routes";
import { resolveProviderSyncCompletion } from "./provider-sync-flow";
import {
defaultDreamSkinTheme,
Expand Down Expand Up @@ -285,9 +286,15 @@ type RelayAggregateMember = {
profileId: string;
weight: number;
};
type RelayAggregateRoute = {
pattern: string;
profileId: string;
priority: number;
};
type RelayAggregateConfig = {
strategy: RelayAggregateStrategy;
members: RelayAggregateMember[];
routes?: RelayAggregateRoute[];
};
type AggregateRelayMember = {
relayId: string;
Expand All @@ -298,6 +305,7 @@ type AggregateRelayProfile = {
name: string;
strategy: RelayAggregateStrategy;
members: AggregateRelayMember[];
routes?: { pattern: string; relayId: string; priority: number }[];
};

type RelayContextSelection = {
Expand Down Expand Up @@ -6477,6 +6485,28 @@ function AggregateRelayProfileEditor({
});
};
const totalWeight = aggregate.members.reduce((total, member) => total + clampAggregateWeight(member.weight), 0);
const routes = aggregate.routes ?? [];
const routeTargetOptions = aggregate.members
.map((member) => {
const candidate = candidates.find((item) => item.id === member.profileId);
return { value: member.profileId, label: candidate?.name || t("未命名供应商") };
})
.filter((option) => option.value.trim() !== "");
const updateRoute = (index: number, patch: Partial<RelayAggregateRoute>) => {
updateAggregate({
...aggregate,
routes: routes.map((route, routeIndex) => (routeIndex === index ? { ...route, ...patch } : route)),
});
};
const removeRoute = (index: number) => {
updateAggregate({ ...aggregate, routes: routes.filter((_, routeIndex) => routeIndex !== index) });
};
const addRoute = () => {
updateAggregate({
...aggregate,
routes: [...routes, { pattern: "", profileId: aggregate.members[0]?.profileId ?? "", priority: 0 }],
});
};

return (
<div className="relay-profile-editor aggregate-editor">
Expand Down Expand Up @@ -6565,11 +6595,68 @@ function AggregateRelayProfileEditor({
<div className="empty">{t("先添加至少 1 个已填写 Base URL / Key 的 API 供应商,再创建聚合供应商。")}</div>
)}
</div>
<div className="aggregate-routes">
<div className="aggregate-routes-head">
<div>
<strong>{t("路由规则")}</strong>
<span>{t("按模型名自动路由到指定成员;仅支持 * 通配符,chat/completions 协议不走路由。")}</span>
</div>
<UiBadge variant="outline">{routes.length}</UiBadge>
</div>
{routes.length ? (
<div className="aggregate-route-list">
{routes.map((route, index) => (
<div className="aggregate-route-row" key={index}>
<Input
onChange={(event) => updateRoute(index, { pattern: event.currentTarget.value })}
placeholder={t("例如 deepseek-*")}
value={route.pattern}
/>
<AppSelect
onChange={(value) => updateRoute(index, { profileId: value })}
options={routeTargetOptions}
value={route.profileId}
/>
{!routeTargetOptions.some((option) => option.value === route.profileId) ? (
<span className="aggregate-route-target-error">{t("路由目标必须是已勾选的聚合成员,请先在成员供应商中勾选。")}</span>
) : null}
<div className="aggregate-route-priority">
<span>{t("优先级")}</span>
<Input
min={0}
onChange={(event) =>
updateRoute(index, { priority: clampAggregateRoutePriority(Number.parseInt(event.currentTarget.value, 10)) })
}
type="number"
value={String(route.priority)}
/>
</div>
<button
className="aggregate-route-remove"
onClick={() => removeRoute(index)}
title={t("删除规则")}
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className="empty">{t("暂无路由规则,未匹配的模型会按聚合策略选择成员。")}</div>
)}
<div>
<Button disabled={!aggregate.members.length} onClick={addRoute} size="sm" variant="secondary">
<Plus className="h-4 w-4" />
{t("添加规则")}
</Button>
</div>
</div>
<div className="relay-grid compact aggregate-preview">
<Metric label={t("策略")} value={aggregateStrategyLabel(aggregate.strategy)} />
<Metric label={t("成员数量")} value={tf("{0} 个", [aggregate.members.length])} />
<Metric label={t("总权重")} value={`${totalWeight}`} />
<Metric label={t("序列化字段")} value="aggregate.strategy / aggregate.members" />
<Metric label={t("序列化字段")} value="aggregate.strategy / aggregate.members / aggregate.routes" />
</div>
<div className="hint-line relay-protocol-hint">
<ShieldCheck className="h-4 w-4" />
Expand Down Expand Up @@ -8447,6 +8534,7 @@ function hydrateAggregateRelayProfile(profile: RelayProfile, aggregate: Aggregat
profileId: member.relayId,
weight: clampAggregateWeight(member.weight),
})),
routes: normalizeAggregateRoutes(aggregate.routes ?? []),
},
};
}
Expand Down Expand Up @@ -9057,6 +9145,7 @@ function normalizeAggregateProfilesFromRelayProfiles(profiles: RelayProfile[]):
const candidates = profiles.filter((profile) => !isAggregateRelayProfile(profile));
return profiles.filter(isAggregateRelayProfile).map((profile) => {
const aggregate = normalizeAggregateConfig(profile.aggregate, candidates);
const memberIds = new Set(aggregate.members.map((member) => member.profileId));
return {
id: profile.id,
name: profile.name || t("聚合供应商"),
Expand All @@ -9065,6 +9154,11 @@ function normalizeAggregateProfilesFromRelayProfiles(profiles: RelayProfile[]):
relayId: member.profileId,
weight: clampAggregateWeight(member.weight),
})),
routes: normalizeAggregateRoutes(aggregate.routes ?? [], { dropEmptyPattern: true, memberIds }).map((route) => ({
pattern: route.pattern,
relayId: route.profileId,
priority: route.priority,
})),
};
});
}
Expand Down Expand Up @@ -9219,6 +9313,7 @@ function removeRelayProfile(settings: BackendSettings, id: string): BackendSetti
aggregate: {
...normalizeAggregateConfig(profile.aggregate, []),
members: normalizeAggregateConfig(profile.aggregate, []).members.filter((member) => member.profileId !== id),
routes: normalizeAggregateConfig(profile.aggregate, []).routes ?? [],
},
},
{ ...settings, relayProfiles: profiles },
Expand Down Expand Up @@ -9295,7 +9390,14 @@ function normalizeAggregateConfig(
seen.add(member.profileId);
return { profileId: member.profileId, weight: clampAggregateWeight(member.weight) };
});
return { strategy, members };
const routes = (aggregate?.routes ?? [])
.filter((route) => route.pattern.trim() !== "" || route.profileId.trim() !== "")
.map((route) => ({
pattern: route.pattern.trim(),
profileId: route.profileId,
priority: clampAggregateRoutePriority(route.priority),
}));
return { strategy, members, routes };
}

function aggregateMemberCandidates(settings: BackendSettings, aggregateId: string): RelayProfile[] {
Expand Down Expand Up @@ -9326,7 +9428,22 @@ function aggregateStrategyHelp(strategy: RelayAggregateStrategy): string {

function aggregateRelayProfileValidation(profile: RelayProfile): string | null {
const aggregate = normalizeAggregateConfig(profile.aggregate, []);
return aggregate.members.length >= 1 ? null : t("聚合供应商至少需要勾选 1 个已填写 Base URL / Key 的 API 供应商。");
if (aggregate.members.length < 1) {
return t("聚合供应商至少需要勾选 1 个已填写 Base URL / Key 的 API 供应商。");
}
const issues = validateAggregateRoutes(
aggregate.routes ?? [],
new Set(aggregate.members.map((member) => member.profileId)),
);
if (!issues) return null;
const first = issues[0];
if (first.code === "emptyPattern") {
return t("路由规则的模型匹配模式不能为空。");
}
if (first.code === "invalidPriority") {
return tf("路由规则「{0}」的优先级必须是大于等于 0 的整数。", [first.pattern]);
}
return tf("路由规则「{0}」的目标供应商必须是聚合成员,请先将其勾选为成员。", [first.pattern]);
}

function numberOrDefault(value: string, fallback: number) {
Expand Down
164 changes: 164 additions & 0 deletions apps/codex-plus-manager/src/aggregate-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* @description 聚合供应商路由规则纯函数单测(Node 内置 test runner,与 model-windows.test.ts 同风格)
* @author Albert_Luo
* @email 480199976@qq.com
* @date 2026-08-05
*/

import assert from "node:assert";
import { describe, it } from "node:test";
import {
clampAggregateRoutePriority,
normalizeAggregateRoutes,
validateAggregateRoutes,
type AggregateRouteLike,
} from "./aggregate-routes.ts";

describe("clampAggregateRoutePriority", () => {
it("NaN 归零", () => {
assert.strictEqual(clampAggregateRoutePriority(NaN), 0);
});
it("负数归零", () => {
assert.strictEqual(clampAggregateRoutePriority(-5), 0);
assert.strictEqual(clampAggregateRoutePriority(-0.1), 0);
});
it("超过 999 钳到 999", () => {
assert.strictEqual(clampAggregateRoutePriority(1500), 999);
});
it("小数四舍五入", () => {
assert.strictEqual(clampAggregateRoutePriority(3.4), 3);
assert.strictEqual(clampAggregateRoutePriority(3.6), 4);
});
it("边界值保持不变", () => {
assert.strictEqual(clampAggregateRoutePriority(0), 0);
assert.strictEqual(clampAggregateRoutePriority(999), 999);
});
});

describe("normalizeAggregateRoutes", () => {
it("trim pattern 并 clamp priority", () => {
const routes: AggregateRouteLike[] = [
{ pattern: " deepseek-* ", profileId: "member-a", priority: 1200 },
{ pattern: "gpt-*", profileId: "member-b", priority: -3 },
];
const result = normalizeAggregateRoutes(routes);
assert.deepStrictEqual(result, [
{ pattern: "deepseek-*", profileId: "member-a", priority: 999 },
{ pattern: "gpt-*", profileId: "member-b", priority: 0 },
]);
});
it("默认保留空 pattern 规则(不再静默删除)", () => {
const routes: AggregateRouteLike[] = [
{ pattern: " ", profileId: "member-a", priority: 1 },
{ pattern: "", profileId: "", priority: 2 },
];
const result = normalizeAggregateRoutes(routes);
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0]!.pattern, "");
assert.strictEqual(result[1]!.pattern, "");
});
it("dropEmptyPattern 时过滤空 pattern 规则", () => {
const routes: AggregateRouteLike[] = [
{ pattern: " ", profileId: "member-a", priority: 1 },
{ pattern: "deepseek-*", profileId: "member-b", priority: 2 },
];
const result = normalizeAggregateRoutes(routes, { dropEmptyPattern: true });
assert.deepStrictEqual(result, [{ pattern: "deepseek-*", profileId: "member-b", priority: 2 }]);
});
it("memberIds 过滤非成员规则", () => {
const routes: AggregateRouteLike[] = [
{ pattern: "deepseek-*", profileId: "member-a", priority: 1 },
{ pattern: "gpt-*", profileId: "removed-provider", priority: 2 },
];
const result = normalizeAggregateRoutes(routes, { memberIds: new Set(["member-a"]) });
assert.deepStrictEqual(result, [{ pattern: "deepseek-*", profileId: "member-a", priority: 1 }]);
});
it("clampPriority false 时保留原始 priority", () => {
const routes: AggregateRouteLike[] = [{ pattern: "deepseek-*", profileId: "member-a", priority: -7 }];
const result = normalizeAggregateRoutes(routes, { clampPriority: false });
assert.deepStrictEqual(result, [{ pattern: "deepseek-*", profileId: "member-a", priority: -7 }]);
});
it("空数组返回空数组", () => {
assert.deepStrictEqual(normalizeAggregateRoutes([]), []);
});
});

describe("validateAggregateRoutes", () => {
const memberIds = new Set(["member-a", "member-b"]);

it("空 pattern(有目标)报 emptyPattern", () => {
const issues = validateAggregateRoutes([{ pattern: " ", profileId: "member-a", priority: 1 }], memberIds);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "emptyPattern");
});
it("pattern 与 profileId 全空的行跳过", () => {
const issues = validateAggregateRoutes([{ pattern: "", profileId: "", priority: 1 }], memberIds);
assert.strictEqual(issues, null);
});
it("非整数 priority 报 invalidPriority", () => {
const issues = validateAggregateRoutes([{ pattern: "deepseek-*", profileId: "member-a", priority: 1.5 }], memberIds);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "invalidPriority");
assert.strictEqual(issues[0]!.pattern, "deepseek-*");
});
it("NaN priority 报 invalidPriority", () => {
const issues = validateAggregateRoutes([{ pattern: "deepseek-*", profileId: "member-a", priority: NaN }], memberIds);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "invalidPriority");
});
it("负数 priority 报 invalidPriority", () => {
const issues = validateAggregateRoutes([{ pattern: "deepseek-*", profileId: "member-a", priority: -1 }], memberIds);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "invalidPriority");
});
it("非成员 profileId 报 notMember", () => {
const issues = validateAggregateRoutes(
[{ pattern: "gpt-*", profileId: "removed-provider", priority: 1 }],
memberIds,
);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "notMember");
assert.strictEqual(issues[0]!.pattern, "gpt-*");
});
it("返回全部错误而非仅第一个", () => {
const issues = validateAggregateRoutes(
[
{ pattern: "a-*", profileId: "removed-1", priority: 1 },
{ pattern: "b-*", profileId: "removed-2", priority: 2 },
{ pattern: "ok-*", profileId: "member-a", priority: 3 },
],
memberIds,
);
assert.ok(issues);
assert.strictEqual(issues.length, 2);
});
it("合法规则返回 null", () => {
const issues = validateAggregateRoutes(
[
{ pattern: "deepseek-*", profileId: "member-a", priority: 10 },
{ pattern: "gpt-*", profileId: "member-b", priority: 0 },
],
memberIds,
);
assert.strictEqual(issues, null);
});
});

describe("priority 上限", () => {
it("超过 999 报 invalidPriority", () => {
const issues = validateAggregateRoutes(
[{ pattern: "deepseek-*", profileId: "member-a", priority: 1000 }],
new Set(["member-a"]),
);
assert.ok(issues);
assert.strictEqual(issues[0]!.code, "invalidPriority");
assert.strictEqual(issues[0]!.pattern, "deepseek-*");
});
it("边界 999 合法", () => {
const issues = validateAggregateRoutes(
[{ pattern: "deepseek-*", profileId: "member-a", priority: 999 }],
new Set(["member-a"]),
);
assert.strictEqual(issues, null);
});
});
Loading
Loading