diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index dec74d38e..a8ea113f1 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -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, @@ -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; @@ -298,6 +305,7 @@ type AggregateRelayProfile = { name: string; strategy: RelayAggregateStrategy; members: AggregateRelayMember[]; + routes?: { pattern: string; relayId: string; priority: number }[]; }; type RelayContextSelection = { @@ -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) => { + 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 (
@@ -6565,11 +6595,68 @@ function AggregateRelayProfileEditor({
{t("先添加至少 1 个已填写 Base URL / Key 的 API 供应商,再创建聚合供应商。")}
)}
+
+
+
+ {t("路由规则")} + {t("按模型名自动路由到指定成员;仅支持 * 通配符,chat/completions 协议不走路由。")} +
+ {routes.length} +
+ {routes.length ? ( +
+ {routes.map((route, index) => ( +
+ updateRoute(index, { pattern: event.currentTarget.value })} + placeholder={t("例如 deepseek-*")} + value={route.pattern} + /> + updateRoute(index, { profileId: value })} + options={routeTargetOptions} + value={route.profileId} + /> + {!routeTargetOptions.some((option) => option.value === route.profileId) ? ( + {t("路由目标必须是已勾选的聚合成员,请先在成员供应商中勾选。")} + ) : null} +
+ {t("优先级")} + + updateRoute(index, { priority: clampAggregateRoutePriority(Number.parseInt(event.currentTarget.value, 10)) }) + } + type="number" + value={String(route.priority)} + /> +
+ +
+ ))} +
+ ) : ( +
{t("暂无路由规则,未匹配的模型会按聚合策略选择成员。")}
+ )} +
+ +
+
- +
@@ -8447,6 +8534,7 @@ function hydrateAggregateRelayProfile(profile: RelayProfile, aggregate: Aggregat profileId: member.relayId, weight: clampAggregateWeight(member.weight), })), + routes: normalizeAggregateRoutes(aggregate.routes ?? []), }, }; } @@ -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("聚合供应商"), @@ -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, + })), }; }); } @@ -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 }, @@ -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[] { @@ -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) { diff --git a/apps/codex-plus-manager/src/aggregate-routes.test.ts b/apps/codex-plus-manager/src/aggregate-routes.test.ts new file mode 100644 index 000000000..789077820 --- /dev/null +++ b/apps/codex-plus-manager/src/aggregate-routes.test.ts @@ -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); + }); +}); diff --git a/apps/codex-plus-manager/src/aggregate-routes.ts b/apps/codex-plus-manager/src/aggregate-routes.ts new file mode 100644 index 000000000..e019f99c4 --- /dev/null +++ b/apps/codex-plus-manager/src/aggregate-routes.ts @@ -0,0 +1,77 @@ +/** + * @description 聚合供应商路由规则纯函数:priority 清洗、规范化、校验(供 App.tsx 与单测共用) + * @author Albert_Luo + * @email 480199976@qq.com + * @date 2026-08-05 + */ + +export type AggregateRouteLike = + | { pattern: string; profileId: string; priority: number } + | { pattern: string; relayId: string; priority: number }; + +export type AggregateRouteNormalized = { + pattern: string; + profileId: string; + priority: number; +}; + +export type AggregateRouteValidationIssue = + | { code: "emptyPattern"; pattern: string } + | { code: "invalidPriority"; pattern: string } + | { code: "notMember"; pattern: string }; + +export type NormalizeAggregateRoutesOptions = { + /** 只保留 pattern trim 后非空的规则(默认 false:保留空 pattern 供校验提示) */ + dropEmptyPattern?: boolean; + /** 提供时,只保留目标 profileId 在成员集合中的规则 */ + memberIds?: ReadonlySet; + /** 是否清洗 priority(默认 true,与保存方向一致) */ + clampPriority?: boolean; +}; + +export function clampAggregateRoutePriority(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(999, Math.round(value))); +} + +export function normalizeAggregateRoutes( + routes: AggregateRouteLike[], + options: NormalizeAggregateRoutesOptions = {}, +): AggregateRouteNormalized[] { + const { dropEmptyPattern = false, memberIds, clampPriority = true } = options; + return routes + .map((route) => ({ + pattern: route.pattern.trim(), + profileId: "profileId" in route ? route.profileId : route.relayId, + priority: clampPriority ? clampAggregateRoutePriority(route.priority) : route.priority, + })) + .filter((route) => { + if (dropEmptyPattern && !route.pattern) return false; + if (memberIds && !memberIds.has(route.profileId)) return false; + return true; + }); +} + +export function validateAggregateRoutes( + routes: AggregateRouteLike[], + memberIds: ReadonlySet, +): AggregateRouteValidationIssue[] | null { + const issues: AggregateRouteValidationIssue[] = []; + for (const route of routes) { + const pattern = route.pattern.trim(); + const profileId = "profileId" in route ? route.profileId : route.relayId; + if (!pattern && !profileId.trim()) continue; + if (!pattern) { + issues.push({ code: "emptyPattern", pattern: "" }); + continue; + } + if (!Number.isInteger(route.priority) || route.priority < 0 || route.priority > 999) { + issues.push({ code: "invalidPriority", pattern }); + continue; + } + if (!memberIds.has(profileId)) { + issues.push({ code: "notMember", pattern }); + } + } + return issues.length ? issues : null; +} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index f58b1f382..b09c6f960 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -706,6 +706,19 @@ export const EN_PLAIN: Record = { "An aggregate provider needs at least 1 selected API provider with a Base URL / Key filled in.", "聚合策略": "Aggregate strategy", "聚合配置只引用已有供应商,不复制 Key 和配置文件": "Aggregate config only references existing providers; it doesn't copy keys or config files", + "该路由的目标供应商不可用或未选择,请重新选择或删除该规则。": "Route target provider is unavailable or unselected; pick one or delete this rule.", + "路由规则": "Route rules", + "按模型名自动路由到指定成员;仅支持 * 通配符,chat/completions 协议不走路由。": + "Automatically route models to a member; only * wildcards are supported and chat/completions traffic is not routed.", + "例如 deepseek-*": "e.g. deepseek-*", + "优先级": "Priority", + "删除规则": "Delete rule", + "暂无路由规则,未匹配的模型会按聚合策略选择成员。": + "No route rules yet; unmatched models fall back to the aggregate strategy.", + "添加规则": "Add rule", + "路由规则的模型匹配模式不能为空。": "Route rule model pattern must not be empty.", + "路由目标必须是已勾选的聚合成员,请先在成员供应商中勾选。": + "Route target must be a checked aggregate member; check it under Member providers first.", "脚本市场": "Script marketplace", "自动接管": "Auto-takeover", "覆盖图片": "Overlay image", @@ -827,6 +840,10 @@ export const EN_PLAIN: Record = { // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. export const EN_TEMPLATE: Record = { + "路由规则「{0}」的优先级必须是大于等于 0 的整数。": + "Route rule \"{0}\" priority must be an integer greater than or equal to 0.", + "路由规则「{0}」的目标供应商必须是聚合成员,请先将其勾选为成员。": + "Route rule \"{0}\" target provider must be an aggregate member; select it as a member first.", "作者:{0} · {1}": "Author: {0} · {1}", "清单更新于 {0},安装后会保存到“我的主题”。": "Manifest updated {0}. Installed themes are saved under My themes.", ",清理 {0} 条失效任务索引": ", pruned {0} stale task index entry/entries", diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index ab757374d..d054f737e 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -2071,6 +2071,86 @@ body { min-height: 34px; } +.aggregate-routes { + display: grid; + gap: 10px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--secondary) / 0.22); + padding: 12px; +} + +.aggregate-routes-head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.aggregate-routes-head > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.aggregate-routes-head span, +.aggregate-route-priority > span { + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.35; +} + +.aggregate-route-list { + display: grid; + gap: 8px; +} + +.aggregate-route-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 110px 32px; + gap: 10px; + align-items: center; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + padding: 10px; +} + +.aggregate-route-priority { + display: grid; + gap: 4px; +} + +.aggregate-route-priority input { + min-height: 34px; +} + +.aggregate-route-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid transparent; + color: hsl(var(--muted-foreground)); + background: transparent; + cursor: pointer; +} + +.aggregate-route-target-error { + grid-column: 1 / -1; + color: hsl(var(--status-error)); + font-size: 12px; + line-height: 1.35; +} + +.aggregate-route-remove:hover { + color: hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.08); + border-color: hsl(var(--destructive) / 0.2); +} + .aggregate-preview { margin-top: 0; } diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 543da9524..4841e642a 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -549,8 +549,15 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( .get("stream") .and_then(Value::as_bool) .unwrap_or(false); + let model = request_json + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); let context = RotationContext { conversation_id: conversation_id_from_responses_request(&request_json), + model, }; let relay = crate::relay_rotation::select_relay_for_request(&settings, context)?; let mut relays = vec![relay.clone()]; @@ -560,8 +567,14 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( let relay_count = relays.len(); for (attempt, relay) in relays.into_iter().enumerate() { validate_upstream(&relay)?; - let (endpoint, upstream_body, wire_api) = - upstream_request_parts(&relay, request_json.clone(), request_path).await?; + let model_override = aggregate_upstream_model_override(&settings, &relay); + let (endpoint, upstream_body, wire_api) = upstream_request_parts( + &relay, + request_json.clone(), + request_path, + model_override.as_deref(), + ) + .await?; let has_more_candidates = attempt + 1 < relay_count; let header_timeout = response_header_timeout(is_stream); let _ = crate::diagnostic_log::append_diagnostic_log( @@ -678,6 +691,16 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( anyhow::bail!("未找到可用的聚合供应商成员") } +fn aggregate_upstream_model_override( + settings: &crate::settings::BackendSettings, + relay: &crate::settings::RelayProfile, +) -> Option { + settings.active_aggregate_relay_profile()?; + let model = crate::relay_config::relay_profile_model(relay); + let model = model.trim(); + (!model.is_empty()).then(|| model.to_string()) +} + pub async fn open_models_proxy_request( original_user_agent: Option<&str>, ) -> anyhow::Result { @@ -831,13 +854,20 @@ pub async fn open_chat_completions_proxy_request( async fn upstream_request_parts( relay: &crate::settings::RelayProfile, - request_json: Value, + mut request_json: Value, request_path: &str, + model_override: Option<&str>, ) -> anyhow::Result<(String, Value, UpstreamWireApi)> { let compact = is_responses_compact_proxy_path(request_path); if compact && relay.protocol == RelayProtocol::ChatCompletions { anyhow::bail!("Chat Completions 协议暂不支持 Responses compact 请求"); } + if let Some(model) = model_override + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request_json["model"] = json!(model); + } let mut body = match relay.protocol { RelayProtocol::Responses => request_json, RelayProtocol::ChatCompletions => responses_to_chat_completions(request_json)?, diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 9e9e3de63..754e1406a 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -2237,6 +2237,9 @@ fn complete_relay_profile_config(profile: &RelayProfile) -> anyhow::Result, + pub model: Option, } impl RotationContext { pub fn for_conversation(conversation_id: impl Into) -> Self { Self { conversation_id: Some(conversation_id.into()), + model: None, + } + } +} + +/// 路由匹配过程结果信息,供 protocol_proxy 记录 diagnostic_log +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RouteMatchInfo { + /// 规则命中且目标成员有效(含目标 relayId 与规则 pattern) + Matched { relay_id: String, pattern: String }, + /// 规则命中但目标成员无效(不存在或缺少 base_url/api_key),已跳过 + SkippedInvalidRelay { relay_id: String, pattern: String }, + /// 路由未命中(仅当 routes 非空且 model 存在时产生) + Unmatched, +} + +/// 通配符匹配:pattern 与 model 均 trim + 忽略大小写;`*` 匹配任意 0+ 字符; +/// 无 `*` 时精确匹配;含 `*` 时按 `*` 分段,各段在 model 中按序且不重叠出现 +pub fn match_route_pattern(pattern: &str, model: &str) -> bool { + let pattern = pattern.trim().to_lowercase(); + let model = model.trim().to_lowercase(); + if pattern.is_empty() { + return false; + } + if !pattern.contains('*') { + return pattern == model; + } + let segments: Vec<&str> = pattern.split('*').collect(); + let mut position = 0usize; + for (index, segment) in segments.iter().enumerate() { + if segment.is_empty() { + continue; + } + if index == 0 { + // 首段必须是前缀 + if !model[position..].starts_with(segment) { + return false; + } + position += segment.len(); + } else if index == segments.len() - 1 { + // 末段必须是后缀,且不能与已匹配部分重叠 + if !model.ends_with(segment) || model.len() - segment.len() < position { + return false; + } + position = model.len(); + } else { + // 中间段按序出现 + let Some(relative) = model[position..].find(segment) else { + return false; + }; + position += relative + segment.len(); } } + true } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,19 +159,39 @@ impl RelayRotationSelector { settings: &BackendSettings, context: RotationContext, ) -> Result { + self.select_with_outcomes(settings, context) + .map(|(relay, _outcomes)| relay) + } + + /// 选择 relay 的同时返回路由匹配过程结果,供调用方记录 diagnostic_log。 + /// 保证日志与实际选择来自同一次匹配,避免二次匹配带来的结果与日志不一致 + pub fn select_with_outcomes( + &mut self, + settings: &BackendSettings, + context: RotationContext, + ) -> Result<(RelayProfile, Vec), SelectionError> { validate_aggregate_members(settings, &self.aggregate)?; - let relay_id = match self.aggregate.strategy { - AggregateRelayStrategy::Failover => self.member_id_at(self.failover_index), - AggregateRelayStrategy::ConversationRoundRobin => { - self.select_for_conversation(context.conversation_id) + let (routed_relay_id, outcomes) = + match_route_for_aggregate(settings, &self.aggregate, context.model.as_deref()); + let relay_id = if let Some(relay_id) = routed_relay_id { + relay_id + } else { + match self.aggregate.strategy { + AggregateRelayStrategy::Failover => self.member_id_at(self.failover_index), + AggregateRelayStrategy::ConversationRoundRobin => { + self.select_for_conversation(context.conversation_id) + } + AggregateRelayStrategy::RequestRoundRobin => self.select_next_request(), + AggregateRelayStrategy::WeightedRoundRobin => self.select_next_weighted(), } - AggregateRelayStrategy::RequestRoundRobin => self.select_next_request(), - AggregateRelayStrategy::WeightedRoundRobin => self.select_next_weighted(), }; - relay_profile_by_id(settings, &relay_id).ok_or_else(|| SelectionError::UnknownMemberRelay { - aggregate_id: self.aggregate.id.clone(), - relay_id, - }) + let relay = relay_profile_by_id(settings, &relay_id).ok_or_else(|| { + SelectionError::UnknownMemberRelay { + aggregate_id: self.aggregate.id.clone(), + relay_id, + } + })?; + Ok((relay, outcomes)) } pub fn peek(&self, settings: &BackendSettings) -> Result { @@ -205,10 +282,45 @@ pub fn select_relay_for_request( if needs_new_selector { *guard = Some(RelayRotationSelector::from_settings(settings)?); } - guard + let model = context.model.clone(); + let (relay, outcomes) = guard .as_mut() .expect("selector initialized") - .select(settings, context) + .select_with_outcomes(settings, context)?; + log_route_outcomes(model.as_deref(), &outcomes); + Ok(relay) +} + +/// 将路由匹配过程结果写入 diagnostic_log,事件名与字段与旧 protocol_proxy 侧实现完全一致; +/// 仅在聚合选择路径被调用(无活动聚合时由调用方直接返回单 relay,不产生路由日志) +fn log_route_outcomes(model: Option<&str>, outcomes: &[RouteMatchInfo]) { + for outcome in outcomes { + let (event, detail) = match outcome { + RouteMatchInfo::Matched { relay_id, pattern } => ( + "protocol_proxy.route_matched", + json!({ + "relayId": relay_id, + "model": model, + "rule": pattern, + }), + ), + RouteMatchInfo::SkippedInvalidRelay { relay_id, pattern } => ( + "protocol_proxy.route_skipped_invalid_relay", + json!({ + "relayId": relay_id, + "model": model, + "rule": pattern, + }), + ), + RouteMatchInfo::Unmatched => ( + "protocol_proxy.route_unmatched", + json!({ + "model": model, + }), + ), + }; + let _ = append_diagnostic_log(event, detail); + } } pub fn select_relay_for_probe(settings: &BackendSettings) -> Result { @@ -320,6 +432,82 @@ fn validate_aggregate_members( Ok(()) } +/// 按 model 执行聚合路由规则匹配,返回命中的成员 relayId 与过程结果信息 +fn match_route_for_aggregate( + settings: &BackendSettings, + aggregate: &AggregateRelayProfile, + model: Option<&str>, +) -> (Option, Vec) { + let mut outcomes = Vec::new(); + if aggregate.routes.is_empty() { + return (None, outcomes); + } + let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) else { + return (None, outcomes); + }; + let mut routes: Vec<&AggregateRelayRoute> = aggregate.routes.iter().collect(); + // 按 priority 降序;稳定排序保证同 priority 保持数组顺序 + routes.sort_by(|left, right| right.priority.cmp(&left.priority)); + for route in routes { + if route.pattern.trim().is_empty() { + continue; + } + if !match_route_pattern(&route.pattern, model) { + continue; + } + if relay_is_available(settings, aggregate, &route.relay_id) { + outcomes.push(RouteMatchInfo::Matched { + relay_id: route.relay_id.clone(), + pattern: route.pattern.clone(), + }); + return (Some(route.relay_id.clone()), outcomes); + } + outcomes.push(RouteMatchInfo::SkippedInvalidRelay { + relay_id: route.relay_id.clone(), + pattern: route.pattern.clone(), + }); + } + outcomes.push(RouteMatchInfo::Unmatched); + (None, outcomes) +} + +/// 返回当前活动聚合的路由匹配过程结果。 +/// 已不再被生产代码调用:路由日志已并入 select_with_outcomes(与实际选择同一次匹配), +/// 本函数仅保留供测试直接断言路由过程结果;无活动聚合或 model 缺失时为空 +#[allow(dead_code)] +pub fn route_match_outcomes( + settings: &BackendSettings, + model: Option<&str>, +) -> Vec { + let Some(aggregate) = settings.active_aggregate_relay_profile() else { + return Vec::new(); + }; + match_route_for_aggregate(settings, &aggregate, model).1 +} + +/// 路由目标有效性:属于聚合成员且 relay 存在、base_url/api_key 非空。 +/// 除与 validate_aggregate_members 同标准外,额外要求目标必须在 aggregate.members 内, +/// 避免路由把请求导向聚合外部的独立 relay +fn relay_is_available( + settings: &BackendSettings, + aggregate: &AggregateRelayProfile, + relay_id: &str, +) -> bool { + if !aggregate + .members + .iter() + .any(|member| member.relay_id == relay_id) + { + return false; + } + settings + .relay_profiles + .iter() + .find(|profile| profile.id == relay_id) + .map(|profile| !profile.base_url.trim().is_empty() && !profile.api_key.trim().is_empty()) + .unwrap_or(false) +} + fn clear_global_selector() { let lock = GLOBAL_SELECTOR.get_or_init(|| Mutex::new(None)); let mut guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 4a2d54bb9..908d91d75 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -134,6 +134,20 @@ pub struct AggregateRelayMember { pub weight: u32, } +/// 聚合供应商按模型名路由规则:model 匹配 pattern 时转发到指定成员 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregateRelayRoute { + /// 模型匹配模式,如 "deepseek-*" / "gpt-*" / "*";仅支持 * 通配符 + pub pattern: String, + /// 目标聚合成员 relayId(必须是本聚合 members 之一) + #[serde(rename = "relayId")] + pub relay_id: String, + /// 数字越大越优先,缺省 0;同 priority 按数组顺序(稳定优先) + #[serde(default)] + pub priority: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct AggregateRelayProfile { @@ -143,6 +157,8 @@ pub struct AggregateRelayProfile { pub strategy: AggregateRelayStrategy, #[serde(default)] pub members: Vec, + #[serde(default)] + pub routes: Vec, } impl Default for RelayProfile { @@ -2109,6 +2125,7 @@ experimental_bearer_token = "sk-existing""# weight: 3, }, ], + routes: Vec::new(), }], active_aggregate_relay_id: "agg".to_string(), ..BackendSettings::default() diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 40ca2a4f4..e40edd6e3 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -1575,6 +1575,80 @@ async fn aggregate_stream_request_sends_sse_accept_header() { fallback_server.abort(); } +#[tokio::test] +async fn aggregate_proxy_rewrites_requested_model_to_selected_member_default_model() { + let _lock = settings_path_test_lock().lock().unwrap(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let fallback = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let fallback_addr = fallback.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = Vec::new(); + let mut chunk = [0; 4096]; + loop { + let read = stream.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + let request = String::from_utf8_lossy(&buffer); + let Some((headers, body)) = request.split_once("\r\n\r\n") else { + continue; + }; + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + }) + .unwrap_or(0); + if body.as_bytes().len() >= content_length { + break; + } + } + let request = String::from_utf8_lossy(&buffer).to_string(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-length: 35\r\ncontent-type: application/json\r\n\r\n{\"id\":\"resp_1\",\"object\":\"response\"}", + ) + .await + .unwrap(); + request + }); + let fallback_server = tokio::spawn(respond_once( + fallback, + "HTTP/1.1 200 OK\r\ncontent-length: 35\r\ncontent-type: application/json\r\n\r\n{\"id\":\"resp_2\",\"object\":\"response\"}", + )); + let mut settings = aggregate_proxy_settings( + "rewrite-model", + format!("http://{addr}/v1"), + format!("http://{fallback_addr}/v1"), + ); + settings.relay_profiles[0].model = "deepseek-v4-pro".to_string(); + + let result = open_responses_proxy_request_with_settings( + r#"{"model":"gpt-5.4","input":"hi","stream":false}"#, + settings, + ) + .await + .unwrap(); + let request = server.await.unwrap(); + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + + assert_eq!(result.status_code, 200); + assert_eq!(body["model"], "deepseek-v4-pro"); + fallback_server.abort(); +} + async fn respond_once(listener: tokio::net::TcpListener, response: &'static str) { let (mut stream, _) = listener.accept().await.unwrap(); let mut buffer = [0; 1024]; @@ -1629,6 +1703,7 @@ fn aggregate_proxy_settings( weight: 1, }, ], + routes: Vec::new(), }], ..BackendSettings::default() } diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 5cb0d3edf..b0d4af703 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -411,10 +411,35 @@ fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot( assert!(result.configured); assert!(updated.contains(r#"wire_api = "responses""#)); + assert!(updated.contains("requires_openai_auth = false")); assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); } +#[test] +fn relay_config_status_treats_aggregate_provider_as_configured_without_openai_auth() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model_provider = "custom" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = false +base_url = "http://127.0.0.1:57321/v1" +experimental_bearer_token = "codex-plus-aggregate" +"#, + ) + .unwrap(); + + let status = relay_config_status_from_home(temp.path()); + + assert!(status.configured); + assert!(!status.requires_openai_auth); + assert!(status.has_bearer_token); +} + #[test] fn chat_protocol_profile_keeps_upstream_base_url_separate_from_codex_proxy() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-plus-core/tests/relay_rotation.rs b/crates/codex-plus-core/tests/relay_rotation.rs index 1c3904db2..1418c5191 100644 --- a/crates/codex-plus-core/tests/relay_rotation.rs +++ b/crates/codex-plus-core/tests/relay_rotation.rs @@ -1,10 +1,11 @@ use codex_plus_core::relay_rotation::{ - RelayRotationSelector, RotationContext, RotationEvent, SelectionError, fallback_relays_after, - record_relay_request_failure, select_relay_for_probe, select_relay_for_request, + RelayRotationSelector, RotationContext, RotationEvent, RouteMatchInfo, SelectionError, + fallback_relays_after, match_route_pattern, record_relay_request_failure, route_match_outcomes, + select_relay_for_probe, select_relay_for_request, }; use codex_plus_core::settings::{ - AggregateRelayMember, AggregateRelayProfile, AggregateRelayStrategy, BackendSettings, - RelayMode, RelayProfile, + AggregateRelayMember, AggregateRelayProfile, AggregateRelayRoute, AggregateRelayStrategy, + BackendSettings, RelayMode, RelayProfile, }; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -44,6 +45,7 @@ fn aggregate(strategy: AggregateRelayStrategy) -> AggregateRelayProfile { weight: 1, }, ], + routes: Vec::new(), } } @@ -62,6 +64,7 @@ fn aggregate_with_id(id: &str, strategy: AggregateRelayStrategy) -> AggregateRel weight: 2, }, ], + routes: Vec::new(), } } @@ -407,3 +410,434 @@ fn select_relay_for_request_rebuilds_selector_when_active_aggregate_changes() { assert_eq!(first.id, "relay-a"); assert_eq!(selected, vec!["relay-a", "relay-b", "relay-b"]); } + +fn route(pattern: &str, relay_id: &str, priority: u32) -> AggregateRelayRoute { + AggregateRelayRoute { + pattern: pattern.to_string(), + relay_id: relay_id.to_string(), + priority, + } +} + +fn context(model: Option<&str>) -> RotationContext { + RotationContext { + conversation_id: None, + model: model.map(str::to_owned), + } +} + +#[test] +fn route_rule_matches_model_and_selects_target_member() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("deepseek-*", "relay-b", 0)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + + assert_eq!(selected.id, "relay-b"); +} + +#[test] +fn route_priority_descending_and_stable_order_within_same_priority() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![ + route("*", "relay-a", 0), + route("deepseek-*", "relay-b", 10), + route("deepseek-chat", "relay-c", 5), + ]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-b"); + + // 同 priority 按数组顺序取第一条(稳定优先) + settings.aggregate_relay_profiles[0].routes = + vec![route("*", "relay-a", 5), route("deepseek-*", "relay-b", 5)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn route_matching_is_case_insensitive() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("DeepSeek-*", "relay-b", 0)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + + assert_eq!(selected.id, "relay-b"); +} + +#[test] +fn route_wildcard_pattern_matrix() { + // 前缀 + assert!(match_route_pattern("deepseek-*", "deepseek-chat")); + assert!(!match_route_pattern("deepseek-*", "glm-4")); + // 后缀 + assert!(match_route_pattern("*-v3", "glm-4-v3")); + assert!(!match_route_pattern("*-v3", "glm-4")); + // 任意位置 + assert!(match_route_pattern("*gpt*", "x-gpt-5-y")); + // 多 * 按序且不重叠 + assert!(match_route_pattern("a*b*c", "a1b2c")); + assert!(!match_route_pattern("a*b*c", "ab")); + assert!(!match_route_pattern("a*b*c", "ac")); + assert!(match_route_pattern("a*a", "aa")); + assert!(!match_route_pattern("a*a", "a")); + // 仅 * + assert!(match_route_pattern("*", "anything")); + // 无 * 精确匹配(大小写不敏感) + assert!(match_route_pattern("DeepSeek-Chat", "deepseek-chat")); + assert!(!match_route_pattern("deepseek-chat", "deepseek-chat-extra")); + // 空白 pattern 不参与匹配 + assert!(!match_route_pattern(" ", "deepseek-chat")); +} + +#[test] +fn route_unmatched_falls_back_to_strategy() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("deepseek-*", "relay-b", 0)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector.select(&settings, context(Some("glm-4"))).unwrap(); + + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn route_empty_routes_preserves_legacy_strategy_behavior() { + let settings = settings(AggregateRelayStrategy::Failover); + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn route_invalid_target_relay_is_skipped_and_falls_back_to_strategy() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.relay_profiles.push(RelayProfile { + id: "relay-empty".to_string(), + name: "empty".to_string(), + ..RelayProfile::default() + }); + // 全部无效(不存在 / 缺 base_url / 缺 api_key)→ 走 strategy + settings.aggregate_relay_profiles[0].routes = vec![ + route("deepseek-*", "ghost", 10), + route("glm-*", "relay-empty", 5), + ]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-a"); + + // 第一条无效,后续有效规则生效 + settings.aggregate_relay_profiles[0].routes = vec![ + route("deepseek-*", "ghost", 10), + route("deepseek-chat", "relay-b", 5), + ]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-b"); +} + +#[test] +fn route_blank_pattern_is_skipped() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route(" ", "relay-b", 10)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn route_not_applied_when_model_is_missing() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("*", "relay-b", 10)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector + .select(&settings, RotationContext::default()) + .unwrap(); + + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn route_matched_request_keeps_member_order_fallback() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("deepseek-*", "relay-b", 0)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-b"); + + let fallbacks = fallback_relays_after(&settings, &selected.id).unwrap(); + assert_eq!( + fallbacks + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(), + vec!["relay-c", "relay-a"] + ); +} + +#[test] +fn route_match_outcomes_reports_events_for_logging() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![ + route("deepseek-*", "ghost", 10), + route("glm-*", "relay-b", 5), + ]; + let outcomes = route_match_outcomes(&settings, Some("deepseek-chat")); + assert_eq!( + outcomes, + vec![ + RouteMatchInfo::SkippedInvalidRelay { + relay_id: "ghost".to_string(), + pattern: "deepseek-*".to_string(), + }, + RouteMatchInfo::Unmatched, + ] + ); + + settings.aggregate_relay_profiles[0].routes = vec![route("deepseek-*", "relay-b", 5)]; + let outcomes = route_match_outcomes(&settings, Some("deepseek-chat")); + assert_eq!( + outcomes, + vec![RouteMatchInfo::Matched { + relay_id: "relay-b".to_string(), + pattern: "deepseek-*".to_string(), + }] + ); + + assert!(route_match_outcomes(&settings, None).is_empty()); + assert!(route_match_outcomes(&settings, Some(" ")).is_empty()); +} + +#[test] +fn route_target_outside_aggregate_members_is_skipped_even_if_relay_exists() { + let mut settings = settings(AggregateRelayStrategy::Failover); + // relay-standalone 存在于 relay_profiles,但不在聚合 members 内 + settings.relay_profiles.push(profile("relay-standalone")); + settings.aggregate_relay_profiles[0].routes = vec![ + route("deepseek-*", "relay-standalone", 10), + route("deepseek-chat", "relay-b", 5), + ]; + + // 非成员(区别于 ghost 的不存在)→ SkippedInvalidRelay,继续匹配后续有效规则 + let outcomes = route_match_outcomes(&settings, Some("deepseek-chat")); + assert_eq!( + outcomes, + vec![ + RouteMatchInfo::SkippedInvalidRelay { + relay_id: "relay-standalone".to_string(), + pattern: "deepseek-*".to_string(), + }, + RouteMatchInfo::Matched { + relay_id: "relay-b".to_string(), + pattern: "deepseek-chat".to_string(), + }, + ] + ); + + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-b"); + + // 全部规则指向非成员 → 走原 strategy + settings.aggregate_relay_profiles[0].routes = + vec![route("deepseek-*", "relay-standalone", 10)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let selected = selector + .select(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn select_with_outcomes_reports_route_outcomes_alongside_selection() { + let mut settings = settings(AggregateRelayStrategy::Failover); + settings.aggregate_relay_profiles[0].routes = vec![route("deepseek-*", "relay-b", 5)]; + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + // 路由命中:select_with_outcomes 与 select 选择一致,同时返回 Matched 过程结果 + let (relay, outcomes) = selector + .select_with_outcomes(&settings, context(Some("deepseek-chat"))) + .unwrap(); + assert_eq!(relay.id, "relay-b"); + assert_eq!( + outcomes, + vec![RouteMatchInfo::Matched { + relay_id: "relay-b".to_string(), + pattern: "deepseek-*".to_string(), + }] + ); + + // 路由未命中:select_with_outcomes 走原 strategy,并返回 Unmatched + let (relay, outcomes) = selector + .select_with_outcomes(&settings, context(Some("glm-4"))) + .unwrap(); + assert_eq!(relay.id, "relay-a"); + assert_eq!(outcomes, vec![RouteMatchInfo::Unmatched]); +} + +#[test] +fn select_relay_for_request_applies_route_rules_via_global_selector() { + let _guard = global_selector_test_lock(); + let aggregate_id = "agg-route-global"; + let mut aggregate = aggregate_with_id(aggregate_id, AggregateRelayStrategy::Failover); + aggregate.routes = vec![route("deepseek-*", "relay-b", 0)]; + let settings = BackendSettings { + relay_profiles: vec![ + profile("relay-a"), + profile("relay-b"), + RelayProfile { + id: aggregate_id.to_string(), + name: "聚合".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + aggregate_relay_profiles: vec![aggregate], + active_relay_id: aggregate_id.to_string(), + active_aggregate_relay_id: aggregate_id.to_string(), + ..BackendSettings::default() + }; + + let selected = select_relay_for_request(&settings, context(Some("deepseek-chat"))).unwrap(); + + assert_eq!(selected.id, "relay-b"); +} + +#[test] +fn aggregate_route_serializes_with_camel_case_fields() { + let profile = AggregateRelayProfile { + id: "agg".to_string(), + name: "聚合".to_string(), + strategy: AggregateRelayStrategy::Failover, + members: Vec::new(), + routes: vec![route("deepseek-*", "relay-b", 10)], + }; + + let serialized = serde_json::to_string(&profile).unwrap(); + + assert!(serialized.contains(r#""pattern":"deepseek-*""#)); + assert!(serialized.contains(r#""relayId":"relay-b""#)); + assert!(serialized.contains(r#""priority":10"#)); +} + +#[test] +fn aggregate_profile_without_routes_deserializes_to_empty() { + let json = r#"{ + "id": "agg", + "name": "聚合", + "strategy": "failover", + "members": [{"relayId": "relay-a", "weight": 1}] + }"#; + + let profile: AggregateRelayProfile = serde_json::from_str(json).unwrap(); + + assert!(profile.routes.is_empty()); + assert_eq!(profile.members.len(), 1); +} + +#[test] +fn aggregate_route_priority_defaults_to_zero_when_missing() { + let json = r#"{ + "id": "agg", + "name": "聚合", + "strategy": "failover", + "members": [], + "routes": [{"pattern": "*", "relayId": "relay-a"}] + }"#; + + let profile: AggregateRelayProfile = serde_json::from_str(json).unwrap(); + + assert_eq!(profile.routes[0].priority, 0); +} + +#[test] +fn aggregate_profile_with_unknown_fields_deserializes() { + let json = r#"{ + "id": "agg", + "name": "聚合", + "strategy": "failover", + "members": [], + "routes": [], + "futureField": 123 + }"#; + + let profile: AggregateRelayProfile = serde_json::from_str(json).unwrap(); + + assert!(profile.routes.is_empty()); +} + +/// 与标准 glob 语义(递归回溯)全量矩阵对比,防止通配符匹配出现假阴/假阳 +#[test] +fn route_wildcard_matches_standard_glob_semantics() { + fn glob_reference(pattern: &str, model: &str) -> bool { + let p: Vec = pattern.trim().to_lowercase().chars().collect(); + let m: Vec = model.trim().to_lowercase().chars().collect(); + fn rec(p: &[char], m: &[char]) -> bool { + match p.split_first() { + None => m.is_empty(), + Some(('*', rest)) => (0..=m.len()).any(|take| rec(rest, &m[take..])), + Some((c, rest)) => m.first() == Some(c) && rec(rest, &m[1..]), + } + } + rec(&p, &m) + } + let patterns = [ + "a", "b", "c", "a*", "*a", "a*b", "*a*", "a*b*c", "a*a", "*ab*", + "ab*cd*ef", "*a*b*a", "**", "*", "a**b", "ab*ab", "*ab*ab*", "a*b*a*c", + "**a**", "*a*a*a*", "deepseek-*", "*-v3", "gpt-*", + ]; + let models = [ + "", "a", "b", "ab", "ba", "aa", "aab", "aba", "abb", "abc", "aabb", + "abab", "abxa", "aabxa", "abcabc", "abcc", "cba", "baab", "ababa", + "deepseek-chat", "gpt-5.4", "glm-4-v3", + ]; + let mismatches = patterns + .iter() + .flat_map(|pattern| { + models.iter().filter_map(|model| { + let actual = match_route_pattern(pattern, model); + let expected = glob_reference(pattern, model); + (actual != expected).then(|| (*pattern, *model, expected, actual)) + }) + }) + .collect::>(); + assert!(mismatches.is_empty(), "mismatches: {mismatches:?}"); +} + +/// 多字节字符(中文/emoji)与大小写展开(İ -> i̇)不应 panic 或误判 +#[test] +fn route_wildcard_handles_unicode_without_panic() { + assert!(match_route_pattern("deepseek-*", "deepseek-chat中文版")); + assert!(match_route_pattern("*版", "deepseek-chat中文版")); + assert!(match_route_pattern("*gpt*", "中文gpt-5")); + assert!(match_route_pattern("*中*文*", "中文测试")); + assert!(match_route_pattern("*İ*", "i̇xyz")); + assert!(match_route_pattern("*i̇*", "xyzİ")); +} diff --git a/crates/codex-plus-core/tests/relay_switch.rs b/crates/codex-plus-core/tests/relay_switch.rs index d9170ae6a..4ba5fbb0e 100644 --- a/crates/codex-plus-core/tests/relay_switch.rs +++ b/crates/codex-plus-core/tests/relay_switch.rs @@ -222,6 +222,7 @@ fn switch_to_aggregate_relay_allows_empty_config_snapshot() { relay_id: "api".to_string(), weight: 1, }], + routes: Vec::new(), }], active_aggregate_relay_id: "agg".to_string(), ..BackendSettings::default()