From f869cb7d9b23b5726d1b1f2e10ebbf70606fe214 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:55:03 +0800 Subject: [PATCH] refactor(engine): a key is bound to one route, not layered onto a default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gave the configuration the right vocabulary and left the old semantics underneath it. A key carried a *list* of routes, the default route was a `default: bool` on an arbitrary member, and every request stitched the default's rules together with the assigned ones before evaluating the result. The objects were new; the engine was still the flat accumulating table it had always been. **The default route is the one a key walks when it is not bound to anything — not one every key has to pass through.** That distinction is the whole model. The first reading means evaluation always looks at exactly one rule list. The second means every request begins by joining two. So: default_route: 默认 # top-level name routes: - name: 默认 rules: [...] - name: 长上下文 rules: [...] clients: - name: codex key: tw-… route: 长上下文 # singular. bound, not stacked `Engine::rules_for` is five lines now: look up what the key is bound to, fall back to the default route's name, return that route's rules. There is no join. **`default_route` is a top-level name rather than a flag on a member, and that removes a validation rule rather than adding one.** "At most one default" existed only to maintain a uniqueness the structure did not have. A single field cannot have two values. Two errors replace it, each for a state that was otherwise silent: a `default_route` naming a route that does not exist (unbound keys would match no rule at all, while the config looks complete), and a key bound to a route that does not exist. ## The argument I lost I defended the flat table by claiming cross-rule accumulation was needed to express "everyone gets redaction, codex also swaps the model". That was wrong, and checking it is what settled this: global redaction is `security.redact`, per-upstream is `provider.redact`, and a rule's `guard` only ever tightens one path — it belongs to whichever route that path is in. Nothing needed to accumulate across routes. ## The cost, stated Two routes wanting the same rule each carry a copy. That is the price of binding over layering and it is not being papered over with inheritance, which would put back the complexity just removed and in a place that is harder to explain. The escape hatch needs no new concept: put the shared rules in the default route and leave that key unbound. Co-Authored-By: Claude Opus 5 --- crates/tw-api/src/lib.rs | 7 +- crates/tw-config/src/lib.rs | 29 ++-- crates/tw-config/src/reload.rs | 2 +- crates/tw-control/src/config.rs | 16 +- crates/tw-control/src/lib.rs | 4 +- crates/tw-control/tests/dryrun.rs | 1 - crates/tw-engine/src/engine.rs | 223 +++++++++++++------------ crates/tw-gateway/tests/passthrough.rs | 5 + scripts/smoke.sh | 1 - 9 files changed, 147 insertions(+), 141 deletions(-) diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 4c6cb748..3390f1ae 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -415,10 +415,11 @@ pub struct RuleView { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RouteView { pub name: String, - /// 对所有密钥生效 + /// 没绑路由的密钥走的就是这条 pub default: bool, - /// 分到这条路由的密钥名。**默认路由这里是空的** —— 它不需要分配, - /// 而列出「所有密钥」会让人以为那是一次显式的分配。 + /// **显式绑了这条路由的密钥。**默认路由这里通常是空的 —— 走它的人 + /// 是「没绑」,不是「绑了它」,而把所有密钥列进来会让人以为那是 + /// 一次次显式的选择。 pub clients: Vec, pub rules: Vec, } diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 4a9c260e..fa442412 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -81,6 +81,12 @@ pub struct Config { /// 不写进文件(`generate_initial` 的六行里没有它)。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub routes: Vec, + /// 没绑路由的密钥走哪条。不写就是叫「默认」的那条。 + /// + /// **它是顶层的一个名字,不是某条路由身上的标志** —— 结构上就唯一, + /// 不需要一条「最多一条默认」的校验去维持。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_route: Option, } /// 便于构造,**不代表一份可用的配置** —— `providers` 和 `clients` 都是 @@ -99,6 +105,7 @@ impl Default for Config { limits: Limits::default(), groups: Vec::new(), routes: Vec::new(), + default_route: None, } } } @@ -111,7 +118,7 @@ impl Default for Client { key: String::new(), max_concurrent: None, allow: None, - routes: Vec::new(), + route: None, } } } @@ -141,13 +148,13 @@ impl Config { self.providers.iter().map(|p| p.name.clone()).collect(), self.groups.clone(), self.routes.clone(), - // 密钥 → 它分到的路由。**引擎不认识密钥这个概念** —— - // 它只需要「这个名字要过哪几条路由」,所以映射在这里拍平, + self.default_route.clone(), + // 密钥 → 它绑的那条路由。**引擎不认识密钥这个概念** —— + // 它只需要「这个名字走哪条路由」,所以映射在这里拍平, // 而不是把整个 `clients` 交进去。 self.clients .iter() - .filter(|c| !c.routes.is_empty()) - .map(|c| (c.name.clone(), c.routes.clone())) + .filter_map(|c| c.route.clone().map(|r| (c.name.clone(), r))) .collect(), ) } @@ -323,14 +330,12 @@ pub struct Client { /// - 写 `[]` → **一个都不给**。「临时禁用这个客户端」的正当用法 #[serde(default, skip_serializing_if = "Option::is_none")] pub allow: Option>, - /// 这把密钥额外走哪几条路由,按写的顺序求值。 + /// 这把密钥走哪条路由。**一把密钥一条路由。** /// - /// **默认路由不用写在这里** —— 它对每一把密钥都生效。这里写的是 - /// 「在默认之上,这把密钥还要过哪些规则」。 - /// - /// 一条路由可以分给多把密钥,不用复制。 - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes: Vec, + /// 不写就走默认路由(顶层的 `default_route`)。一条路由可以绑给 + /// 多把密钥,规则只有一份。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, /// 网关密钥。`tw-` 前缀是刻意的:用户在客户端配置里看到它时, /// 一眼就知道这不是某个上游的真 key。 pub key: String, diff --git a/crates/tw-config/src/reload.rs b/crates/tw-config/src/reload.rs index ae76b624..645378e3 100644 --- a/crates/tw-config/src/reload.rs +++ b/crates/tw-config/src/reload.rs @@ -202,7 +202,7 @@ mod tests { fn a_route_pointing_at_a_deleted_group_is_caught_before_anything_swaps_in() { // **语法正确但语义错误的配置最危险**:YAML 完全合法,运行时却会 // 把请求路由到空处。 - let bad = "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\nroutes:\n - name: 默认\n default: true\n rules:\n - name: r\n to: 已经删掉的组\n"; + let bad = "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\nroutes:\n - name: 默认\n rules:\n - name: r\n to: 已经删掉的组\n"; let r = try_parse(bad).unwrap_err(); assert_eq!(r.stage, Stage::Semantics); assert!(r.message.contains("已经删掉的组"), "{r:?}"); diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index a0fffde9..bed862bc 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -393,18 +393,14 @@ mod patch_seq_tests { } #[test] - fn a_new_key_can_carry_its_route_assignment_in_one_write() { - // 建密钥和分配路由是**一次写入**,不是两次 —— 中间那一刻 - // 「有一把没分配路由的密钥」是个用户能看见的错误状态。 + fn a_new_key_can_carry_its_route_binding_in_one_write() { + // 建密钥和绑路由是**一次写入**,不是两次 —— 中间那一刻 + // 「有一把还没绑路由的密钥」是个用户能看见的中间状态。 let steps = resolve_path(CFG, "/clients").unwrap(); - let out = tw_yaml::append( - CFG, - &steps, - "name: codex\nkey: tw-bbb\nroutes:\n - 长上下文", - ) - .unwrap(); + let out = + tw_yaml::append(CFG, &steps, "name: codex\nkey: tw-bbb\nroute: 长上下文").unwrap(); let cfg: tw_config::Config = serde_yaml_ng::from_str(&out).unwrap(); - assert_eq!(cfg.clients[1].routes, vec!["长上下文".to_string()]); + assert_eq!(cfg.clients[1].route.as_deref(), Some("长上下文")); } #[test] diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index bf59375a..a23939a5 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -252,11 +252,11 @@ async fn overview(State(s): State) -> Json { .iter() .map(|set| tw_api::RouteView { name: set.name.clone(), - default: set.default, + default: set.name == engine.default_route(), clients: cfg .clients .iter() - .filter(|c| c.routes.contains(&set.name)) + .filter(|c| c.route.as_deref() == Some(set.name.as_str())) .map(|c| c.name.clone()) .collect(), rules: set diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index a0e3f6f6..18ee1fa8 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -27,7 +27,6 @@ groups: providers: [官方, 中转] routes: - name: 默认 - default: true rules: - name: 带缓存的必须走官方 when: { cache: true } diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 47734986..5b51b8fa 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -337,32 +337,32 @@ pub struct Rule { /// 一条路由 —— 一组按顺序求值的规则。 /// -/// **密钥决定走哪条路由。**`clients[].routes` 里写路由名,那把密钥的请求 -/// 就额外过这条路由的规则。一条路由可以分给多把密钥,不用复制。 +/// **一把密钥绑一条路由。**`clients[].route` 写路由名,那把密钥的每个请求 +/// 就走这条路由的规则表,**只走这一条**。一条路由可以绑给多把密钥, +/// 规则只有一份。 /// -/// 默认路由(`default: true`)**对每一把密钥都生效**,而且永远排在最前。 -/// 它是「首先应该有个默认路由」那句话的落点:公共的脱敏、公共的兜底去向 -/// 写在这儿一次,不必在每条路由里重复。最多只能有一条。 +/// 默认路由(顶层的 `default_route`)是**没绑定时走的那条**,不是 +/// 「所有人都要过的那条」。这个区别是这个模型的支点:前者意味着求值 +/// 永远只看一张规则表,后者意味着每次请求都要先把两张表拼起来。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RouteSet { pub name: String, - /// 对所有密钥生效,且排在所有其他路由之前。最多一条。 - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub default: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, } +/// 默认路由的名字。顶层没写 `default_route` 时用它。 +pub const DEFAULT_ROUTE: &str = "默认"; + impl RouteSet { - /// 一条装着这些规则的默认路由。 + /// 一条叫「默认」的路由,装着这些规则。 /// - /// 「只有默认路由」是最常见的配置形状 —— 没有把任何规则分给具体 - /// 密钥的人,配出来就是这一条。 + /// 「只有一条默认路由」是最常见的配置形状 —— 没给任何密钥绑路由 + /// 的人,配出来就是这一条。 pub fn default_with(rules: Vec) -> Self { Self { - name: "默认".to_string(), - default: true, + name: DEFAULT_ROUTE.to_string(), rules, } } @@ -424,23 +424,20 @@ pub enum RouteError { EmptyGroup(String), #[error("有两条路由都叫 `{0}`。密钥靠名字引用路由,重名的话「分给哪一条」没有答案。")] DuplicateRoute(String), - #[error( - "有不止一条默认路由。默认路由对所有密钥生效,只能有一条 —— 想要多组公共规则就写在同一条里。" - )] - ManyDefaults, - #[error( - "密钥 `{client}` 分到了路由 `{route}`,但没有这条路由。(默认路由不用分配,它对所有密钥生效。)" - )] + #[error("`default_route` 指向 `{0}`,但没有这条路由。没绑路由的密钥会一条规则都过不到。")] + UnknownDefaultRoute(String), + #[error("密钥 `{client}` 绑了路由 `{route}`,但没有这条路由。")] UnknownRoute { client: String, route: String }, #[error(transparent)] Match(#[from] MatchError), } pub struct Engine { - /// 所有路由。默认那条(如果有)已经被排到最前。 sets: Vec, - /// 密钥名 → 它分到的路由名。**默认路由不在这里** —— 它对谁都生效。 - assigned: std::collections::HashMap>, + /// 没绑定时走哪条。 + default_route: String, + /// 密钥名 → 它绑的那条路由名。没有条目 = 走默认。 + bound: std::collections::HashMap, groups: Vec, providers: Vec, } @@ -455,7 +452,8 @@ impl Engine { providers: Vec, mut groups: Vec, mut sets: Vec, - assigned: std::collections::HashMap>, + default_route: Option, + bound: std::collections::HashMap, ) -> Self { // **没有 provider 时什么都不合成。**合成一个空组会让它过不了 // `validate`(空组是配置错误),而零 provider 的配置必须合法 —— @@ -463,6 +461,7 @@ impl Engine { // 上游」都说不出口(tw-config::validate 里那段注释)。 // // 这条今天已经立过一次,又被这里破坏了一次。测试抓住了。 + let default_route = default_route.unwrap_or_else(|| DEFAULT_ROUTE.to_string()); if sets.iter().all(|s| s.rules.is_empty()) && !providers.is_empty() { const ALL: &str = "__all__"; groups.push(Group { @@ -480,25 +479,18 @@ impl Engine { deny: None, guard: None, }; - // **填进已有的那条默认路由,不要再追加一条。**两条默认路由是 - // `validate` 明确拒绝的状态,而这里凭空造一条就会制造它。 - match sets.iter_mut().find(|s| s.default) { + match sets.iter_mut().find(|s| s.name == default_route) { Some(d) => d.rules.push(fallback), None => sets.push(RouteSet { - name: "默认".to_string(), - default: true, + name: default_route.clone(), rules: vec![fallback], }), } } - // 默认路由排最前。**顺序就是语义**:`to` 取第一条命中的,所以 - // 默认路由里的兜底去向必须排在后面,而它的 `deny` 和 `set` 想 - // 先生效就得排在前面 —— 两者都要,所以位置交给用户,这里只保证 - // 「默认在其他路由之前」这一件事。 - sets.sort_by_key(|s| !s.default); Self { sets, - assigned, + default_route, + bound, groups, providers, } @@ -518,36 +510,35 @@ impl Engine { let sets = if rules.is_empty() { Vec::new() } else { - vec![RouteSet { - name: "默认".to_string(), - default: true, - rules, - }] + vec![RouteSet::default_with(rules)] }; - Self::new(providers, groups, sets, std::collections::HashMap::new()) + Self::new( + providers, + groups, + sets, + None, + std::collections::HashMap::new(), + ) } - /// 这次请求要过哪些规则。 + /// 这次请求走哪条路由的规则表。 /// - /// 默认路由的规则在前,然后是这把密钥分到的那几条路由,**按 `clients` - /// 里写的顺序**。拼出来的这张表交给 `route()`,求值逻辑一个字没变: - /// `set` 和 `guard` 从每一条命中的累积,`to` 和 `deny` 取第一条。 + /// **一张表,没有拼接。**密钥绑了哪条就是哪条;没绑就是默认那条。 + /// 这是「绑定」和「叠加」的全部区别,也是这个函数只有五行的原因。 /// - /// **认不出的密钥只走默认路由。**那是首次运行、或者一把刚建还没分配 - /// 路由的密钥 —— 它应该能用,只是没有额外规则。 - fn rules_for(&self, client: &str) -> Vec<&Rule> { - let mut out: Vec<&Rule> = Vec::new(); - for s in self.sets.iter().filter(|s| s.default) { - out.extend(s.rules.iter()); - } - if let Some(names) = self.assigned.get(client) { - for n in names { - if let Some(s) = self.sets.iter().find(|s| !s.default && &s.name == n) { - out.extend(s.rules.iter()); - } - } - } - out + /// 认不出的密钥、或者刚建还没绑路由的密钥,都走默认路由 —— 它应该 + /// 能用,只是没有为它定制的规则。 + fn rules_for(&self, client: &str) -> &[Rule] { + let want = self + .bound + .get(client) + .map(String::as_str) + .unwrap_or(&self.default_route); + self.sets + .iter() + .find(|s| s.name == want) + .map(|s| s.rules.as_slice()) + .unwrap_or(&[]) } /// 所有规则,不分归属。校验和「这条规则存在吗」用它。 @@ -567,19 +558,19 @@ impl Engine { return Err(RouteError::DuplicateRoute(set.name.clone())); } } - if self.sets.iter().filter(|s| s.default).count() > 1 { - return Err(RouteError::ManyDefaults); + // 默认路由指了一个不存在的名字。**没绑路由的密钥会一条规则都 + // 不过** —— 请求全部落到「没有任何规则命中」,而配置看起来完整。 + if !self.sets.is_empty() && !self.sets.iter().any(|s| s.name == self.default_route) { + return Err(RouteError::UnknownDefaultRoute(self.default_route.clone())); } - // 分配里写了一个不存在的路由名 —— 那把密钥会静默地只走默认路由, + // 绑了一个不存在的路由名 —— 那把密钥会静默地退回默认路由, // 而用户以为他配的规则在生效。 - for (client, names) in &self.assigned { - for n in names { - if !self.sets.iter().any(|s| !s.default && &s.name == n) { - return Err(RouteError::UnknownRoute { - client: client.clone(), - route: n.clone(), - }); - } + for (client, name) in &self.bound { + if !self.sets.iter().any(|s| &s.name == name) { + return Err(RouteError::UnknownRoute { + client: client.clone(), + route: name.clone(), + }); } } for r in self.all_rules() { @@ -757,9 +748,14 @@ impl Engine { } /// 这把密钥实际会过的规则,按求值顺序。**试算和「按密钥看」都用它。** - pub fn rules_for_client(&self, client: &str) -> Vec<&Rule> { + pub fn rules_for_client(&self, client: &str) -> &[Rule] { self.rules_for(client) } + + /// 没绑定时走哪条路由。 + pub fn default_route(&self) -> &str { + &self.default_route + } } #[cfg(test)] @@ -822,10 +818,9 @@ mod tests { assert!(e.validate().is_ok(), "零 provider 的配置必须合法"); } - fn set_of(name: &str, default: bool, to: &str) -> RouteSet { + fn set_of(name: &str, to: &str) -> RouteSet { RouteSet { name: name.into(), - default, rules: vec![Rule { name: format!("{name} 的规则"), when: When::default(), @@ -837,26 +832,23 @@ mod tests { } } - fn assign(pairs: &[(&str, &[&str])]) -> std::collections::HashMap> { + fn bind(pairs: &[(&str, &str)]) -> std::collections::HashMap { pairs .iter() - .map(|(c, rs)| { - ( - c.to_string(), - rs.iter().map(|r| r.to_string()).collect::>(), - ) - }) + .map(|(c, r)| (c.to_string(), r.to_string())) .collect() } #[test] - fn the_default_route_applies_to_every_key_and_the_assigned_one_stacks_on_top() { - // 这是整个模型的那句话:**默认路由对谁都生效,分配的叠在它上面**。 + fn a_key_walks_exactly_one_route_and_an_unbound_key_walks_the_default() { + // **这是整个模型的那句话。**默认路由是「没绑定时走的那条」, + // 不是「所有人都要过的那条」—— 所以求值永远只看一张规则表。 let e = Engine::new( vec!["a".into(), "b".into()], vec![], - vec![set_of("默认", true, "a"), set_of("codex 专用", false, "b")], - assign(&[("codex", &["codex 专用"])]), + vec![set_of("默认", "a"), set_of("codex 专用", "b")], + None, + bind(&[("codex", "codex 专用")]), ); let names = |c: &str| { e.rules_for_client(c) @@ -864,30 +856,44 @@ mod tests { .map(|r| r.name.clone()) .collect::>() }; - // 没分配路由的密钥只走默认 —— 而且它能用,不是一个错误状态 assert_eq!(names("claude-code"), vec!["默认 的规则"]); - // 分了的,默认在前,分配的在后 - assert_eq!(names("codex"), vec!["默认 的规则", "codex 专用 的规则"]); + // **绑了的只走它自己那条,默认那条的规则不会跟着来** + assert_eq!(names("codex"), vec!["codex 专用 的规则"]); } #[test] fn one_route_can_serve_several_keys_without_being_copied() { - // 这条是「给路由起名」换来的东西:两把密钥引用同一个名字, + // 这条是「给路由起名」换来的东西:两把密钥绑同一个名字, // 规则只有一份。**复制出来的两份,迟早只有一份被改。** let e = Engine::new( vec!["a".into(), "b".into()], vec![], - vec![set_of("长上下文", false, "b")], - assign(&[("codex", &["长上下文"]), ("cline", &["长上下文"])]), + vec![set_of("默认", "a"), set_of("长上下文", "b")], + None, + bind(&[("codex", "长上下文"), ("cline", "长上下文")]), ); assert_eq!(e.rules_for_client("codex").len(), 1); - assert_eq!(e.rules_for_client("cline").len(), 1); assert_eq!( e.rules_for_client("codex")[0].name, e.rules_for_client("cline")[0].name ); } + #[test] + fn the_default_route_can_be_any_route_by_name() { + // 默认路由是顶层的一个名字,不是某条路由身上的标志。 + // **结构上就唯一** —— 没有「最多一条」这种校验要维持。 + let e = Engine::new( + vec!["a".into(), "b".into()], + vec![], + vec![set_of("平时", "a"), set_of("别的", "b")], + Some("别的".into()), + Default::default(), + ); + assert!(e.validate().is_ok()); + assert_eq!(e.rules_for_client("谁都行")[0].name, "别的 的规则"); + } + #[test] fn two_routes_with_the_same_name_are_rejected() { // 密钥靠名字引用路由。重名的话「分给哪一条」没有答案, @@ -895,21 +901,28 @@ mod tests { let e = Engine::new( vec!["a".into()], vec![], - vec![set_of("重名", false, "a"), set_of("重名", false, "a")], + vec![set_of("重名", "a"), set_of("重名", "a")], + Some("重名".into()), Default::default(), ); assert_eq!(e.validate(), Err(RouteError::DuplicateRoute("重名".into()))); } #[test] - fn two_default_routes_are_rejected() { + fn a_default_route_that_names_nothing_is_caught_at_load() { + // **没绑路由的密钥会一条规则都过不到** —— 请求全部落到 + // 「没有任何规则命中」,而配置看起来是完整的。 let e = Engine::new( vec!["a".into()], vec![], - vec![set_of("默认一", true, "a"), set_of("默认二", true, "a")], + vec![set_of("有名有姓", "a")], + Some("打错的名字".into()), Default::default(), ); - assert_eq!(e.validate(), Err(RouteError::ManyDefaults)); + assert_eq!( + e.validate(), + Err(RouteError::UnknownDefaultRoute("打错的名字".into())) + ); } #[test] @@ -919,8 +932,9 @@ mod tests { let e = Engine::new( vec!["a".into()], vec![], - vec![set_of("默认", true, "a")], - assign(&[("codex", &["打错的名字"])]), + vec![set_of("默认", "a")], + None, + bind(&[("codex", "打错的名字")]), ); assert_eq!( e.validate(), @@ -931,19 +945,6 @@ mod tests { ); } - #[test] - fn the_default_route_is_evaluated_first_whatever_order_it_was_written_in() { - // 顺序就是语义:`to` 取第一条命中的。默认路由写在文件末尾也要 - // 排到最前,否则「公共的兜底」会变成「谁都到不了的最后一条」。 - let e = Engine::new( - vec!["a".into(), "b".into()], - vec![], - vec![set_of("别的", false, "b"), set_of("默认", true, "a")], - assign(&[("c", &["别的"])]), - ); - assert_eq!(e.rules_for_client("c")[0].name, "默认 的规则"); - } - #[test] fn layer_zero_is_not_a_special_case_in_the_code() { // 它被展开成「一个全量 fallback 组 + 一条兜底规则」,所以求值 diff --git a/crates/tw-gateway/tests/passthrough.rs b/crates/tw-gateway/tests/passthrough.rs index e07347e4..e8111f71 100644 --- a/crates/tw-gateway/tests/passthrough.rs +++ b/crates/tw-gateway/tests/passthrough.rs @@ -63,6 +63,7 @@ async fn start_upstream(sse: bool) -> (SocketAddr, Arc>) { async fn start_gateway(upstream: SocketAddr) -> SocketAddr { let cfg = Config { + default_route: None, version: 1, listen: Listen::default(), clients: vec![Client { @@ -235,6 +236,7 @@ async fn a_request_emits_the_four_lifecycle_events_in_order() { // 一个跑六分钟的流式请求在列表里要六分钟后才出现。 let (up, _) = start_upstream(true).await; let cfg = Config { + default_route: None, version: 1, listen: Listen::default(), clients: vec![Client { @@ -343,6 +345,7 @@ async fn an_unreachable_upstream_emits_a_failure_event_and_a_502() { l.local_addr().unwrap().port() }; let cfg = Config { + default_route: None, version: 1, listen: Listen::default(), clients: vec![Client { @@ -408,6 +411,7 @@ async fn a_rule_sends_opus_to_one_upstream_and_everything_else_to_another() { let (b, seen_b) = start_upstream(false).await; let cfg = Config { + default_route: None, version: 1, listen: Listen::default(), clients: vec![Client { @@ -513,6 +517,7 @@ async fn with_no_routes_at_all_requests_still_go_somewhere() { // 那条纪律就是假的。 let (up, seen) = start_upstream(false).await; let cfg = Config { + default_route: None, version: 1, listen: Listen::default(), clients: vec![Client { diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 98702900..11d53b38 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -147,7 +147,6 @@ groups: providers: [relay] routes: - name: 默认 - default: true rules: - name: 冒烟:这条必须走 relay,不许转移 to: 只走中转