diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 4df4c61..0b46fc1 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -199,7 +199,6 @@ control.signin_gone control.signin_response_unusable control.store_off control.store_unavailable -control.unknown_probe_class control.upstream_in_use control.upstream_not_found control.user_empty diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 1a704aa..91dc9b4 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -400,10 +400,8 @@ slug_enum! { pub enum ProbeMode { /// 网关自己答 Intercept = "intercept", - /// 照常走路由 - Route = "route", - /// 原样转发 - Passthrough = "passthrough", + /// 照常走路由,带着类别标记 + Forward = "forward", } } @@ -717,7 +715,13 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 不给网页调的 `POST /plugins/confirmed`、`PUT /plugins/{id}/confirmed`、 /// `POST /plugins/{id}/approve/confirmed`。只改数据、停用、删、排顺序,装、打开、改、批准不碰 /// 工具调用的插件,都不用点头 —— `CreatePlugin` 和 `ApprovePluginFile` 因此给网页调了。 -pub const CONTROL_API_VERSION: u32 = 35; +/// +/// **36 起辅助请求只有两档**:[`ProbeMode`] 是 `intercept` / `forward`,`route` 和 +/// `passthrough` 合成了 `forward`(照常走规则,带着类别标记,`when.intent` 能命中)。 +/// [`DryRunOutcome`] 删了 `passthrough`:转发的那几类照常求值规则。[`RouteSave`] 的 +/// `route_probes` 删了,消息码 `control.unknown_probe_class` 跟着删。配置里 +/// `client_probes` 的取值同样只剩 `intercept` / `forward`。 +pub const CONTROL_API_VERSION: u32 = 36; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] @@ -1738,15 +1742,14 @@ pub struct ProxyFault { /// 一类客户端辅助请求的处置。 /// -/// **这一段以前在界面上完全不存在,而它的缺席是连锁的**:路由条件 -/// `when.intent` 只有在对应那一类被配成 `route` 时才可能命中,所以 -/// 界面上那些写了 `intent` 的规则永远不会生效,而用户无从知道为什么。 +/// 路由条件 `when.intent` 只对 `forward` 的类别命中 —— `intercept` 的一个字节都不出 +/// 本机,到不了规则那一层。 #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct ProbeView { /// `health_check` / `warmup` / `titling` / `topic_detect` / `suggestion` pub id: ProbeClass, - /// `intercept` / `route` / `passthrough` + /// `intercept` / `forward` pub mode: ProbeMode, } @@ -2904,12 +2907,6 @@ pub struct RouteSave { /// 不给就不动密钥的选择 #[serde(default, skip_serializing_if = "Option::is_none")] pub keys: Option>, - /// 和路由一起写入:把这几类客户端辅助请求设为「交给路由」。 - /// - /// **规则里的辅助请求条件只对交给路由的类别生效** —— 其余类别的请求在 - /// 进路由之前就被本地应答或原样放行,不带类别标记,那个条件永远不满足 - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub route_probes: Vec, } /// 删除一条路由。 @@ -4416,8 +4413,6 @@ slug_enum! { Unavailable = "unavailable", /// 客户端的辅助请求,网关自己答 Intercepted = "intercepted", - /// 客户端的辅助请求,原样转发 - Passthrough = "passthrough", } } @@ -4434,11 +4429,11 @@ pub struct DryRunResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub strategy: Option, /// `route` | `deny` | `no_match` | `unavailable`(选中的上游都服务不了, - /// 见 `skipped`)| `intercepted` | `passthrough` + /// 见 `skipped`)| `intercepted` /// - /// **后两个说的是这个请求压根没到规则那一层。**客户端自己发的辅助 - /// 请求先过 `client_probes`:本地应答的一个字节都不出本机,原样放行的 - /// 直接转发 —— 两种情况下 `trace` 都是空的,因为确实一条规则都没求值。 + /// **`intercepted` 说的是这个请求压根没到规则那一层。**客户端自己发的辅助 + /// 请求先过 `client_probes`:本地应答的一个字节都不出本机,`trace` 是空的, + /// 因为确实一条规则都没求值。转发的那几类照常走规则,和普通请求一样。 pub outcome: DryRunOutcome, /// 命中的规则名 pub rule: Option, diff --git a/crates/tw-config/src/probes.rs b/crates/tw-config/src/probes.rs index f7b697c..2637ce1 100644 --- a/crates/tw-config/src/probes.rs +++ b/crates/tw-config/src/probes.rs @@ -12,6 +12,12 @@ //! 省钱,那是把一个功能关掉了。 //! //! 所以默认只拦 A 类。 +//! +//! **只有两档:本地应答,或者转发。**转发的请求照常走路由规则,并且带着 +//! 它是哪一类的标记,所以规则里写了 `when.intent` 就生效。以前还有一档 +//! 「转发但不带标记」,和带标记的那档只差在 `intent` 条件能不能命中 —— +//! 两档在界面上看着是两种去向,实际走的是同一套规则,只会让人以为其中 +//! 一档绕开了路由。 use serde::{Deserialize, Serialize}; @@ -20,12 +26,9 @@ use serde::{Deserialize, Serialize}; pub enum ProbeAction { /// 本地应答,一个字节都不发给上游 Intercept, - /// 原样放行 - Passthrough, - /// 交给路由规则。**前提是你手里真有一个更便宜的地方** —— - /// 这类请求本来就走客户端的小模型档,在同一个上游内部已经没有更 - /// 便宜的可换了。 - Route, + /// 照常走路由规则,带着类别标记让 `when.intent` 能命中。规则里没写 + /// 这个条件时,它和一个普通请求走的是同一条路 + Forward, } impl ProbeAction { /// 写进 YAML 的那个词。**界面写回的是它,不是中文标签** —— @@ -33,8 +36,7 @@ impl ProbeAction { pub fn slug(&self) -> &'static str { match self { ProbeAction::Intercept => "intercept", - ProbeAction::Passthrough => "passthrough", - ProbeAction::Route => "route", + ProbeAction::Forward => "forward", } } } @@ -59,7 +61,7 @@ impl ClientProbes { /// 五种辅助请求各自怎么处理。 /// -/// **默认值的分布本身就是那条判据**:A 类拦、B 类放行。改这个默认值 +/// **默认值的分布本身就是那条判据**:A 类拦、B 类转发。改这个默认值 /// 之前先回答「拦掉之后用户会不会少一样东西」。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -70,22 +72,22 @@ pub struct ClientProbes { /// A 类。正文恰好是 `Warmup` #[serde(default = "intercept")] pub warmup: ProbeAction, - /// **B 类,默认放行。**拦了的话每个会话都叫同一个名字。 - #[serde(default = "passthrough")] + /// **B 类,默认转发。**拦了的话每个会话都叫同一个名字。 + #[serde(default = "forward")] pub titling: ProbeAction, /// B 类 - #[serde(default = "passthrough")] + #[serde(default = "forward")] pub topic_detect: ProbeAction, /// B 类 - #[serde(default = "passthrough")] + #[serde(default = "forward")] pub suggestion: ProbeAction, } fn intercept() -> ProbeAction { ProbeAction::Intercept } -fn passthrough() -> ProbeAction { - ProbeAction::Passthrough +fn forward() -> ProbeAction { + ProbeAction::Forward } impl Default for ClientProbes { @@ -93,9 +95,9 @@ impl Default for ClientProbes { Self { health_check: ProbeAction::Intercept, warmup: ProbeAction::Intercept, - titling: ProbeAction::Passthrough, - topic_detect: ProbeAction::Passthrough, - suggestion: ProbeAction::Passthrough, + titling: ProbeAction::Forward, + topic_detect: ProbeAction::Forward, + suggestion: ProbeAction::Forward, } } } @@ -111,24 +113,24 @@ mod tests { let d = ClientProbes::default(); assert_eq!(d.health_check, ProbeAction::Intercept); assert_eq!(d.warmup, ProbeAction::Intercept); - assert_eq!(d.titling, ProbeAction::Passthrough); - assert_eq!(d.topic_detect, ProbeAction::Passthrough); - assert_eq!(d.suggestion, ProbeAction::Passthrough); + assert_eq!(d.titling, ProbeAction::Forward); + assert_eq!(d.topic_detect, ProbeAction::Forward); + assert_eq!(d.suggestion, ProbeAction::Forward); } #[test] fn only_the_key_you_wrote_moves_the_rest_stay_default() { // 配置文件里写一行不该把另外四行也重置掉。 - let c: ClientProbes = serde_yaml_ng::from_str("titling: route").unwrap(); - assert_eq!(c.titling, ProbeAction::Route); + let c: ClientProbes = serde_yaml_ng::from_str("titling: intercept").unwrap(); + assert_eq!(c.titling, ProbeAction::Intercept); assert_eq!(c.health_check, ProbeAction::Intercept); - assert_eq!(c.suggestion, ProbeAction::Passthrough); + assert_eq!(c.suggestion, ProbeAction::Forward); } #[test] fn a_misspelled_probe_name_is_an_error() { // `warmup` 写成 `warm_up` 被静默忽略的话,用户会以为预热请求 // 已经被拦住了,而它一直在计费。 - assert!(serde_yaml_ng::from_str::("warm_up: route").is_err()); + assert!(serde_yaml_ng::from_str::("warm_up: forward").is_err()); } } diff --git a/crates/tw-config/src/wire.rs b/crates/tw-config/src/wire.rs index e1dd942..4cac48a 100644 --- a/crates/tw-config/src/wire.rs +++ b/crates/tw-config/src/wire.rs @@ -116,8 +116,7 @@ impl From for tw_api::ProbeMode { fn from(a: ProbeAction) -> Self { match a { ProbeAction::Intercept => Self::Intercept, - ProbeAction::Route => Self::Route, - ProbeAction::Passthrough => Self::Passthrough, + ProbeAction::Forward => Self::Forward, } } } diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index b6039b0..ead7bf1 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -889,29 +889,26 @@ pub fn sections() -> Vec
{ row( "titling", Kind::Enum(probe_actions), - Def::Is("passthrough"), + Def::Is("forward"), t( - "Requests that name a session. Passed through by default: intercepting them gives every session the same title.", - "为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。", + "Requests that name a session. Forwarded by default: intercepting them gives every session the same title.", + "为会话起标题的请求。默认转发:拦下后所有会话都会是同一个标题。", ), ), row( "topic_detect", Kind::Enum(probe_actions), - Def::Is("passthrough"), + Def::Is("forward"), t( - "Topic detection. Passed through by default.", - "话题检测。默认放行。", + "Topic detection. Forwarded by default.", + "话题检测。默认转发。", ), ), row( "suggestion", Kind::Enum(probe_actions), - Def::Is("passthrough"), - t( - "Suggestions. Passed through by default.", - "建议。默认放行。", - ), + Def::Is("forward"), + t("Suggestions. Forwarded by default.", "建议。默认转发。"), ), ], }, @@ -1411,8 +1408,8 @@ pub fn sections() -> Vec
{ Kind::OneOrMany, Def::Unset, t( - "A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing.", - "客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。", + "A client helper request: `assistant_internal` for any of them, or one class (`titling`). Classes set to `intercept` in `client_probes` are answered locally and never reach routing.", + "客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。在 `client_probes` 中设为 `intercept` 的类别由本地应答,不会进入路由。", ), ), row( diff --git a/crates/tw-control/src/dryrun.rs b/crates/tw-control/src/dryrun.rs index ee0b612..a671f9c 100644 --- a/crates/tw-control/src/dryrun.rs +++ b/crates/tw-control/src/dryrun.rs @@ -131,9 +131,8 @@ pub async fn dry_run( .find(|(class, _)| class.slug() == f.intent) .and_then(|(_, action)| match action { tw_config::ProbeAction::Intercept => Some(DryRunOutcome::Intercepted), - tw_config::ProbeAction::Passthrough => Some(DryRunOutcome::Passthrough), - // 交给路由的那些照常往下走,和普通请求一样 - tw_config::ProbeAction::Route => None, + // 转发的那些照常往下走,和普通请求一样 + tw_config::ProbeAction::Forward => None, }); // 每条规则的下场。**先走一遍这个,再问结果** —— 顺序反过来的话, diff --git a/crates/tw-control/src/routes.rs b/crates/tw-control/src/routes.rs index 47a4bcf..2d2098a 100644 --- a/crates/tw-control/src/routes.rs +++ b/crates/tw-control/src/routes.rs @@ -56,11 +56,11 @@ async fn create_route( if cfg.engine().routes().iter().any(|r| r.name == set.name) { return Err(name_taken("route", &set.name)); } - let mut out = edit::upsert(text, edit::ROUTES, None, &mapping(&set)?)?; - if let Some(keys) = &req.keys { - out = assign_keys(&out, cfg, None, &set.name, keys)?; + let out = edit::upsert(text, edit::ROUTES, None, &mapping(&set)?)?; + match &req.keys { + Some(keys) => assign_keys(&out, cfg, None, &set.name, keys), + None => Ok(out), } - route_probes(&out, &req.route_probes) }) .await .map_err(apply_fail)?; @@ -94,10 +94,10 @@ async fn update_route( // 的密钥指向一条不存在的路由,会被校验拒掉 out = refs::rename_route(&out, cfg, &name, &set.name)?; } - if let Some(keys) = &req.keys { - out = assign_keys(&out, cfg, Some(&name), &set.name, keys)?; + match &req.keys { + Some(keys) => assign_keys(&out, cfg, Some(&name), &set.name, keys), + None => Ok(out), } - route_probes(&out, &req.route_probes) }) .await .map_err(apply_fail)?; @@ -224,26 +224,6 @@ fn assign_keys( Ok(out) } -/// 把这几类辅助请求设为「交给路由」。 -fn route_probes(text: &str, probes: &[String]) -> Result { - let mut out = text.to_string(); - for p in probes { - // 总称不是一个可以单独设置的类别 - if p == "assistant_internal" || !INTENTS.contains(&p.as_str()) { - return Err(invalid(msg!( - "control.unknown_probe_class", class = p => - "there is no auxiliary-request class `{class}`" - ))); - } - out = edit::set( - &out, - &[Step::key("client_probes"), Step::key(p.as_str())], - Some(&Value::String("route".to_string())), - )?; - } - Ok(out) -} - fn route_of(client: usize) -> [Step; 3] { [ Step::key("clients"), diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index 4ea7361..64cdfd9 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -186,12 +186,9 @@ async fn the_mismatch_is_the_condition_that_really_failed() { assert_eq!((m.field.slug(), m.got.as_str()), ("max_tokens", "")); // `assistant_internal` 是五类里的任意一类:卡住的是模型,不是它。 - // - // **起标题要先设成「交给路由」,这个场景才到得了规则** —— 出厂是 - // 原样放行,那时试算在规则之前就短路了,一条都不求值 - let (_d2, routed) = app_with(&format!("{CFG}client_probes:\n titling: route\n")); + // 起标题出厂是转发,带着类别到得了规则 let r = run( - &routed, + &app, r#"{"model":"m","intent":"titling","draft":{"name":"x","rules":[ {"name":"辅助请求走中转","conditions":[ {"field":"intent","values":["assistant_internal"]}, @@ -347,25 +344,44 @@ async fn a_locally_answered_probe_never_reaches_the_rules() { assert!(r.trace.is_empty(), "不该列出没求过的规则:{r:?}"); } +/// 转发的那几类和普通请求一样走完规则。起标题出厂就是转发。 #[tokio::test] -async fn a_passed_through_probe_says_so_instead_of_naming_a_rule() { +async fn a_forwarded_probe_is_evaluated_like_any_other_request() { let (_d, app) = app(); - // 起标题出厂是原样放行:它转发,但不经过规则 let r = run(&app, r#"{"model":"claude-sonnet-4-5","intent":"titling"}"#).await; - assert_eq!(r.outcome.slug(), "passthrough"); - assert!(r.trace.is_empty()); -} + assert_eq!(r.outcome.slug(), "route"); + assert_eq!(r.rule.as_deref(), Some("其余都试试")); + assert!(!r.trace.is_empty(), "转发的要走完规则:{r:?}"); -/// 设成「交给路由」的那些,和普通请求一样走完规则。 -#[tokio::test] -async fn a_routed_probe_is_evaluated_like_any_other_request() { - let (_d, app) = app_with(&format!("{CFG}client_probes:\n health_check: route\n")); + // 出厂本地应答的那几类,设成转发也一样 + let (_d, app) = app_with(&format!("{CFG}client_probes:\n health_check: forward\n")); let r = run( &app, r#"{"model":"claude-sonnet-4-5","intent":"health_check"}"#, ) .await; assert_eq!(r.outcome.slug(), "route"); - assert_eq!(r.rule.as_deref(), Some("其余都试试")); - assert!(!r.trace.is_empty(), "交给路由的要走完规则:{r:?}"); + assert!(!r.trace.is_empty(), "{r:?}"); +} + +/// 规则里的辅助请求条件对转发的类别成立,不用再另外设置什么。 +#[tokio::test] +async fn an_intent_rule_catches_a_forwarded_probe() { + let (_d, app) = app(); + let draft = r#"{"name":"x","rules":[ + {"name":"标题走中转","conditions":[{"field":"intent","values":["titling"]}],"to":"中转"}, + {"name":"兜底","to":"官方"}]}"#; + let r = run( + &app, + &format!(r#"{{"model":"claude-sonnet-4-5","intent":"titling","draft":{draft}}}"#), + ) + .await; + assert_eq!(r.rule.as_deref(), Some("标题走中转"), "{r:?}"); + // 普通请求不带类别,同一条规则不命中 + let r = run( + &app, + &format!(r#"{{"model":"claude-sonnet-4-5","draft":{draft}}}"#), + ) + .await; + assert_eq!(r.rule.as_deref(), Some("兜底"), "{r:?}"); } diff --git a/crates/tw-control/tests/routes.rs b/crates/tw-control/tests/routes.rs index 4824fa0..829c2eb 100644 --- a/crates/tw-control/tests/routes.rs +++ b/crates/tw-control/tests/routes.rs @@ -399,42 +399,6 @@ async fn a_rule_that_only_rewrites_can_be_saved() { assert!(codex["rules"][0].get("guard").is_none(), "{codex}"); } -#[tokio::test] -async fn an_assistant_request_condition_can_route_those_requests_in_the_same_write() { - // 生成标题默认原样放行,不带类别标记:条件写进去也永远不满足 - let b = bed(BASE); - let (st, v) = call( - &b.app, - "PUT", - "/routes/codex", - json!({ "route": { "name": "codex", "rules": [ - { "name": "标题用小模型", - "conditions": [{ "field": "intent", "values": ["titling"] }], - "set": { "model": "claude-haiku-4-5" } }, - catch_all("主力"), - ]}, "route_probes": ["titling"] }), - ) - .await; - assert_eq!(st, StatusCode::OK, "{v}"); - let probes = b.parsed().client_probes; - assert_eq!(probes.titling, tw_config::ProbeAction::Route); - assert_eq!( - probes.topic_detect, - tw_config::ProbeAction::Passthrough, - "没选的不动" - ); - - let (st, _) = call( - &b.app, - "PUT", - "/routes/codex", - json!({ "route": { "name": "codex", "rules": [catch_all("主力")] }, - "route_probes": ["assistant_internal"] }), - ) - .await; - assert_eq!(st, StatusCode::BAD_REQUEST); -} - #[tokio::test] async fn deleting_a_route_sends_its_keys_where_the_user_chose() { let b = bed(BASE); diff --git a/crates/tw-engine/src/rule.rs b/crates/tw-engine/src/rule.rs index bbf7e21..e9a3909 100644 --- a/crates/tw-engine/src/rule.rs +++ b/crates/tw-engine/src/rule.rs @@ -43,9 +43,9 @@ pub struct When { /// 客户端的辅助请求。`assistant_internal` 匹配全部五类, /// 也可以写具体的那一类,比如 `titling`。 /// - /// **只有把那类请求配成 `route` 才会有值** —— `intercept` 的根本 - /// 到不了路由,`passthrough` 也不打标记。所以这个条件写了却不生效 - /// 时,要去看的是 `client_probes` 而不是规则本身。 + /// **配成 `intercept` 的那几类永远不会命中** —— 它们在本地应答,根本 + /// 到不了路由。所以这个条件写了却不生效时,要去看的是 `client_probes` + /// 而不是规则本身。 #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, /// **阶段二专用**:路由决定完之后,选中的是哪个 provider。 diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index 2866217..8533718 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -262,7 +262,7 @@ fn estimated( enum Probe { /// 本地答了,不往下走 Answered(Response), - /// 照常往下走。`route` 档打上的标记,其余是空串 + /// 照常往下走。转发的辅助请求带着它的类别,普通请求是空串 Intent(String), } @@ -298,14 +298,9 @@ fn probe(state: &AppState, rt: &Runtime, req: &Inbound) -> Probe { tracing::debug!(client = %req.client_name, kind = kind.slug(), "answered locally"); Probe::Answered(local_answer(kind, &req.body)) } - // `route` 交给规则处理:打一个标记让 `when: { intent: ... }` - // 能匹配到,然后照常往下走。 - Route => Probe::Intent(kind.slug().to_string()), - // **`passthrough` 不打标记。**打了的话,一条 - // `when: { intent: assistant_internal }` 的规则会在用户还 - // 没把那类请求配成 route 的时候就开始生效 —— 而配置文件里 - // 看不出任何线索。 - Passthrough => Probe::Intent(String::new()), + // 打一个标记让 `when: { intent: ... }` 能匹配到,然后照常往下走。 + // 规则里没写这个条件时,它和普通请求走同一条路 + Forward => Probe::Intent(kind.slug().to_string()), } } diff --git a/crates/tw-gateway/tests/passthrough.rs b/crates/tw-gateway/tests/passthrough.rs index 13d0648..0750d87 100644 --- a/crates/tw-gateway/tests/passthrough.rs +++ b/crates/tw-gateway/tests/passthrough.rs @@ -1498,7 +1498,7 @@ async fn turning_off_the_interception_sends_the_health_check_upstream() { }], vec![], ); - cfg.client_probes.health_check = tw_config::ProbeAction::Passthrough; + cfg.client_probes.health_check = tw_config::ProbeAction::Forward; let gw = serve_cfg(cfg).await; let r = reqwest::Client::new() .post(format!("http://{gw}/v1/messages")) @@ -1513,12 +1513,12 @@ async fn turning_off_the_interception_sends_the_health_check_upstream() { } #[tokio::test] -async fn a_probe_set_to_route_can_be_sent_somewhere_cheaper() { - // 第三个选项。**它成立的前提是你手里真有一个更便宜的地方** —— - // 所以这是高级用法,默认没人会走到这里。 +async fn a_forwarded_probe_can_be_sent_somewhere_cheaper() { + // 转发的辅助请求带着类别走规则,一条 intent 规则就能把它分走。 + // 起标题出厂就是转发,不用另外设置 let (cheap, seen_cheap) = start_upstream(false).await; let (normal, seen_normal) = start_upstream(false).await; - let mut cfg = cfg_with( + let cfg = cfg_with( vec![ Provider { name: "便宜的".into(), @@ -1550,7 +1550,6 @@ async fn a_probe_set_to_route_can_be_sent_somewhere_cheaper() { }, ], ); - cfg.client_probes.titling = tw_config::ProbeAction::Route; let gw = serve_cfg(cfg).await; let send = |body: String| async move { @@ -1566,7 +1565,7 @@ async fn a_probe_set_to_route_can_be_sent_somewhere_cheaper() { send(r#"{"model":"m","messages":[{"role":"user","content":"Please write a 5-10 word title for the following conversation: x"}]}"#.into()).await; assert!( !seen_cheap.lock().unwrap().body.is_empty(), - "配成 route 的标题请求该走便宜的那家" + "转发的标题请求该走便宜的那家" ); assert!(seen_normal.lock().unwrap().body.is_empty()); @@ -1575,61 +1574,6 @@ async fn a_probe_set_to_route_can_be_sent_somewhere_cheaper() { assert!(!seen_normal.lock().unwrap().body.is_empty()); } -#[tokio::test] -async fn an_intent_rule_does_not_fire_while_the_probe_is_still_passthrough() { - // **passthrough 不打标记。**打了的话,一条 intent 规则会在用户还没 - // 把那类请求配成 route 的时候就开始生效 —— 而配置文件里看不出线索。 - let (cheap, seen_cheap) = start_upstream(false).await; - let (normal, seen_normal) = start_upstream(false).await; - let cfg = cfg_with( - vec![ - Provider { - name: "便宜的".into(), - base_url: format!("http://{cheap}"), - key: Some("k".into()), - ..Default::default() - }, - Provider { - name: "正常的".into(), - base_url: format!("http://{normal}"), - key: Some("k".into()), - ..Default::default() - }, - ], - vec![ - tw_engine::Rule { - name: "客户端辅助请求".into(), - when: serde_yaml_ng::from_str("{ intent: assistant_internal }").unwrap(), - to: Some("便宜的".into()), - set: None, - deny: None, - }, - tw_engine::Rule { - name: "兜底".into(), - when: Default::default(), - to: Some("正常的".into()), - set: None, - deny: None, - }, - ], - ); - // titling 保持默认的 passthrough - let gw = serve_cfg(cfg).await; - reqwest::Client::new() - .post(format!("http://{gw}/v1/messages")) - .header("x-api-key", "tw-k") - .header("user-agent", "claude-cli/1.0.0") - .body(r#"{"model":"m","messages":[{"role":"user","content":"Please write a 5-10 word title for the following conversation: x"}]}"#) - .send() - .await - .unwrap(); - assert!( - seen_cheap.lock().unwrap().body.is_empty(), - "还没配成 route,intent 规则就不该命中" - ); - assert!(!seen_normal.lock().unwrap().body.is_empty()); -} - #[tokio::test] async fn a_health_check_works_before_any_upstream_is_configured() { // 「一个 provider 都没有」也是一种「没有可用上游」,而本地应答本来 diff --git a/docs/config.md b/docs/config.md index c75bafd..f542b4d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -556,21 +556,22 @@ pricing: ### `client_probes` Some requests clients send are not the user's: connectivity checks, -warm-ups, session titles, topic detection, suggestions. Each class can be -answered locally (`intercept`, nothing is sent upstream), passed through -(`passthrough`), or handed to the routing rules (`route`, matched with -`when.intent`). The defaults intercept only what nobody would miss. +warm-ups, session titles, topic detection, suggestions. Each class is either +answered locally (`intercept`, nothing is sent upstream) or forwarded +(`forward`). Forwarded ones go through the routing rules like any other +request and carry their class, so a rule can catch them with `when.intent`. +The defaults intercept only what nobody would miss. | Field | Type | Default | Description | |---|---|---|---| -| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | -| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | Warm-up requests. Answered locally by default. | -| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | Requests that name a session. Passed through by default: intercepting them gives every session the same title. | -| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | Topic detection. Passed through by default. | -| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | Suggestions. Passed through by default. | +| `health_check` | `intercept` \| `forward` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | +| `warmup` | `intercept` \| `forward` | `intercept` | Warm-up requests. Answered locally by default. | +| `titling` | `intercept` \| `forward` | `forward` | Requests that name a session. Forwarded by default: intercepting them gives every session the same title. | +| `topic_detect` | `intercept` \| `forward` | `forward` | Topic detection. Forwarded by default. | +| `suggestion` | `intercept` \| `forward` | `forward` | Suggestions. Forwarded by default. | ### `security` @@ -964,7 +965,7 @@ order they are declared. | `image` | bool | — | Whether the request contains an image. | | `thinking` | bool | — | Whether extended thinking is on. | | `stream` | bool | — | Whether the response is streamed. | -| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing. | +| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Classes set to `intercept` in `client_probes` are answered locally and never reach routing. | | `provider_would_be` | string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 13f8bee..3e0307d 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -439,18 +439,18 @@ pricing: ### `client_probes` -客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类都可以在本地应答(`intercept`,不向上游发送任何内容)、原样放行(`passthrough`),或交给路由规则(`route`,由 `when.intent` 匹配)。默认只拦下拦了也不会少任何东西的那几类。 +客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类要么在本地应答(`intercept`,不向上游发送任何内容),要么转发(`forward`)。转发的请求和普通请求一样经过路由规则,并带着自己的类别,规则可以用 `when.intent` 匹配。默认只拦下拦了也不会少任何东西的那几类。 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | -| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | 预热请求。默认在本地应答。 | -| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | 为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。 | -| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | 话题检测。默认放行。 | -| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | 建议。默认放行。 | +| `health_check` | `intercept` \| `forward` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | +| `warmup` | `intercept` \| `forward` | `intercept` | 预热请求。默认在本地应答。 | +| `titling` | `intercept` \| `forward` | `forward` | 为会话起标题的请求。默认转发:拦下后所有会话都会是同一个标题。 | +| `topic_detect` | `intercept` \| `forward` | `forward` | 话题检测。默认转发。 | +| `suggestion` | `intercept` \| `forward` | `forward` | 建议。默认转发。 | ### `security` @@ -774,7 +774,7 @@ security: | `image` | 布尔 | — | 请求是否包含图片。 | | `thinking` | 布尔 | — | 是否开启扩展思考。 | | `stream` | 布尔 | — | 是否流式返回。 | -| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。 | +| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。在 `client_probes` 中设为 `intercept` 的类别由本地应答,不会进入路由。 | | `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 |