Skip to content
Open
1 change: 1 addition & 0 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5875,6 +5875,7 @@ mod tests {
session_provider: codex_plus_core::settings::RelaySessionProvider::Custom,
strategy: codex_plus_core::settings::AggregateRelayStrategy::Failover,
members: Vec::new(),
routes: Vec::new(),
}],
..BackendSettings::default()
};
Expand Down
2 changes: 1 addition & 1 deletion apps/codex-plus-manager/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex++ Manager",
"version": "1.2.50",
"version": "1.2.49",
"identifier": "com.bigpizzav3.codexplusplus.manager",
"build": {
"beforeDevCommand": "npm run vite:dev",
Expand Down
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 @@ -95,6 +95,7 @@ import {
type ImageHandling,
type ModelWindowRow,
} from "./model-windows";
import { clampAggregateRoutePriority, normalizeAggregateRoutes, validateAggregateRoutes } from "./aggregate-routes";
import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files";
import { resolveProviderSyncCompletion } from "./provider-sync-flow";
import { resolveLaunchStatus } from "./launch-status";
Expand Down Expand Up @@ -310,9 +311,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 @@ -324,6 +331,7 @@ type AggregateRelayProfile = {
sessionProvider?: RelaySessionProvider;
strategy: RelayAggregateStrategy;
members: AggregateRelayMember[];
routes?: { pattern: string; relayId: string; priority: number }[];
};

type RelayContextSelection = {
Expand Down Expand Up @@ -7419,6 +7427,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 @@ -7513,11 +7543,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 @@ -9409,6 +9496,7 @@ function hydrateAggregateRelayProfile(profile: RelayProfile, aggregate: Aggregat
profileId: member.relayId,
weight: clampAggregateWeight(member.weight),
})),
routes: normalizeAggregateRoutes(aggregate.routes ?? []),
},
};
}
Expand Down Expand Up @@ -10123,6 +10211,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 @@ -10132,6 +10221,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 @@ -10292,6 +10386,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 @@ -10373,7 +10468,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 @@ -10404,7 +10506,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