diff --git a/Cargo.lock b/Cargo.lock index bfabb162..88d63a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3262,6 +3262,7 @@ dependencies = [ name = "tw-engine" version = "0.68.0" dependencies = [ + "chrono", "serde", "serde_json", "serde_yaml_ng", diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index a86ef981..c2d8eaab 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -265,6 +265,8 @@ engine.pinned_empty engine.pinned_unknown_provider engine.pinned_with_provider_would_be engine.pinned_with_set_model +engine.rule_time_syntax +engine.time_syntax engine.unknown_default_route engine.unknown_route engine.unknown_target diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index ac6ead23..da222dea 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -441,6 +441,8 @@ slug_enum! { MaxTokens = "max_tokens", ToolCount = "tool_count", Intent = "intent", + /// 本地时间窗口:`[days ]HH:MM-HH:MM`,可以写几个 + Time = "time", ProviderWouldBe = "provider_would_be", Cache = "cache", Tools = "tools", @@ -869,6 +871,12 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// 类型。事件跟着改:[`SecretItem`]、[`Event::ContentMatched`]、[`Event::ToolCallFlagged`] /// 多了 `detail`([`SecurityHitDetail`])。细节存在请求记录的库里,和请求一起过期。 /// +/// **45 起规则能按时间分流**:[`ConditionField`] 多了 `time`,值是 `[days ]HH:MM-HH:MM` +/// 的本地时间窗口(见 [`ConditionView::values`]),可以写几个,满足其一即可;路由、试算 +/// 按 core 所在机器那一刻的本地时间求值,试算的 [`MismatchView::got`] 写成 `fri 17:30`。 +/// 写错的值配置加载不了、保存时被拒(`engine.rule_time_syntax`,带规则名和那个值)。 +/// 照 44 写的界面不认这个条件。 +/// /// **45 起会话里的每一轮说得出上下文窗口有多大、被什么占着**:[`TurnView`] 多了 /// `context_window`(答这一轮的那家那个模型的上下文窗口,按**此刻**知道的规格:手写的 /// 优先,其次价目表;不知道是 null)。新端点 `GET /sessions/{id}/turns/{turn}/context` @@ -2399,11 +2407,14 @@ pub struct RuleRewrite { #[cfg_attr(feature = "ts", derive(ts_rs::TS))] pub struct ConditionView { /// `when` 里的键:`model` / `client` / `dialect` / `input_tokens` / - /// `max_tokens` / `tool_count` / `intent` / `provider_would_be` / + /// `max_tokens` / `tool_count` / `intent` / `time` / `provider_would_be` / /// `cache` / `tools` / `image` / `thinking` / `stream` pub field: ConditionField, - /// 写的值。`intent` 和 `provider_would_be` 可以写多个,满足其一即可; - /// 布尔条件是 `true` / `false`;数量条件是比较式(`>200k`) + /// 写的值。`intent`、`time` 和 `provider_would_be` 可以写多个,满足其一即可; + /// 布尔条件是 `true` / `false`;数量条件是比较式(`>200k`);`time` 是 + /// `[days ]HH:MM-HH:MM`(`mon-fri 09:00-18:00`、`sat,sun 00:00-24:00`、`22:00-06:00`: + /// 天用逗号列、`mon-fri` 是一段、不写是每天;起点含、终点不含、终点可以是 `24:00`、 + /// 终点早于起点的是过夜的窗口,天指窗口开始的那一天),按 core 所在机器的本地时间 pub values: Vec, } @@ -5376,9 +5387,10 @@ pub struct RuleTrace { pub struct MismatchView { /// 和 `ConditionView.field` 同一个词表 pub field: ConditionField, - /// 规则里写的值。`intent` 写了多个时逐个列出 + /// 规则里写的值。`intent`、`time` 写了多个时逐个列出 pub want: Vec, - /// 这个请求实际的值。`intent` 为空表示真实的用户请求 + /// 这个请求实际的值。`intent` 为空表示真实的用户请求;`time` 是试算那一刻 core 的 + /// 本地时间,写成 `fri 17:30` pub got: String, } diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 90707b2a..acb99286 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -1981,6 +1981,22 @@ mod msg_codes { assert!(m.text.contains("官方"), "{m:?}"); } + /// 规则的时间窗口写错了,配置读不进来,而且那句话说的是哪条规则、哪个值 + #[test] + fn a_rule_with_a_malformed_time_window_is_refused_at_load_with_rule_and_value() { + let text = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\nroutes:\n - name: default\n rules:\n - name: 白天\n when: { time: ['mon-fri 09:00-18:00', '9-5'] }\n to: a\n - name: 其余\n to: a\n"; + let m = crate::try_parse(text).unwrap_err().msg(); + assert_eq!(m.code, "engine.rule_time_syntax", "{m:?}"); + assert_eq!((m.arg("rule"), m.arg("value")), ("白天", "9-5")); + // 写对了就读得进来,而且原样保留 + let ok = crate::try_parse(&text.replace("'9-5'", "'sat,sun 00:00-24:00'")).unwrap(); + let w = &ok.routes[0].rules[0].when; + assert_eq!( + w.time.as_ref().map(|t| t.iter().collect::>()), + Some(vec!["mon-fri 09:00-18:00", "sat,sun 00:00-24:00"]) + ); + } + #[test] fn a_rejected_config_says_which_stage_and_line_around_serdes_words() { // 语法错:serde 的原话翻不了,外面那一层(哪一关、第几行)带码 diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index dde9e529..b1ecc01a 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -1617,6 +1617,15 @@ pub fn sections() -> Vec
{ "客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。在 `client_probes` 中设为 `intercept` 的类别由本地应答,不会进入路由。", ), ), + row( + "time", + Kind::OneOrMany, + Def::Unset, + t( + "Local time of the machine core runs on, as a window `[days ]HH:MM-HH:MM`: `mon-fri 09:00-18:00`, `sat,sun 00:00-24:00`, `22:00-06:00`. Several windows: any of them. See below.", + "core 所在机器的本地时间落在窗口 `[days ]HH:MM-HH:MM` 里:`mon-fri 09:00-18:00`、`sat,sun 00:00-24:00`、`22:00-06:00`。写几个窗口满足其一即可。见下文。", + ), + ), row( "provider_would_be", Kind::OneOrMany, diff --git a/crates/tw-control/src/dryrun.rs b/crates/tw-control/src/dryrun.rs index 6ea33aca..705c78b3 100644 --- a/crates/tw-control/src/dryrun.rs +++ b/crates/tw-control/src/dryrun.rs @@ -118,6 +118,8 @@ fn facts(req: &tw_api::DryRunRequest) -> RequestFacts { thinking: req.thinking, stream: req.stream, intent: req.intent.clone(), + // 试算按当下:问的是「现在发这个请求会怎么走」 + time: Some(tw_engine::LocalTime::now()), } } @@ -454,6 +456,17 @@ fn unmatched( f.intent.clone(), ); } + // 和 `When::matches` 一样按窗口看;实际的值写成 `fri 17:30`,和条件的写法对得上。 + // 写错的窗口在这之前就被 `check_rules` 拦下了,这里只会是 `Ok` + if let Some(w) = &when.time + && !when.time_matches(f.time).unwrap_or(false) + { + return miss( + ConditionField::Time, + crate::one_or_many(w), + f.time.map(|t| t.to_string()).unwrap_or_default(), + ); + } for (want, got, field) in [ (when.cache, f.cache, ConditionField::Cache), (when.tools, f.tools, ConditionField::Tools), diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index c54a6fcc..6790d897 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -543,6 +543,9 @@ pub(crate) fn describe_when(w: &tw_engine::rule::When) -> Vec1k', max_tokens: '<4k', tool_count: '>2', cache: true, tools: false, image: true, thinking: false, stream: true, - intent: titling, provider_would_be: [a, b] }", + intent: titling, time: 'mon-fri 09:00-18:00', provider_would_be: [a, b] }", ) .unwrap(); let fields = match serde_json::to_value(&full).unwrap() { diff --git a/crates/tw-control/src/routes.rs b/crates/tw-control/src/routes.rs index 2b21eeb6..bef80b13 100644 --- a/crates/tw-control/src/routes.rs +++ b/crates/tw-control/src/routes.rs @@ -448,6 +448,22 @@ fn when_from( ) })?) } + F::Time => { + // 大小写不论,存小写:界面回填时按一种写法读 + let lower: Vec = vals.iter().map(|v| v.to_ascii_lowercase()).collect(); + if let Some(bad) = lower.iter().find(|v| tw_engine::time::parse(v).is_err()) { + return Err(tw_engine::RouteError::TimeSyntax { + rule: rule.to_string(), + value: bad.clone(), + } + .msg()); + } + w.time = Some(match lower.as_slice() { + [] => return Err(no_value()), + [v] => OneOrMany::One(v.clone()), + vs => OneOrMany::Many(vs.to_vec()), + }); + } F::ProviderWouldBe => { w.provider_would_be = Some(many( &|v| cfg.providers.iter().any(|p| p.name == v), @@ -466,11 +482,17 @@ fn when_from( // 那句话是引擎的(`engine.compare.*`),不认识规则名。**码不变,多带一个 // `rule`**,英文前面补上是哪条规则 —— 为此再给每一种写法错误造一个「带 // 规则名」的码,码表就翻了一倍 - w.validate().map_err(|e| { - let mut m = e.msg(); - m.text = format!("rule `{rule}`: {}", m.text); - m.args.insert("rule".into(), rule.to_string()); - m + w.validate().map_err(|e| match e { + // 时间窗口的那一句自己带规则名(上面已经逐个查过,这里只是兜底) + e @ tw_engine::rule::MatchError::BadTime { .. } => { + tw_engine::RouteError::for_rule(rule, e).msg() + } + e => { + let mut m = e.msg(); + m.text = format!("rule `{rule}`: {}", m.text); + m.args.insert("rule".into(), rule.to_string()); + m + } })?; Ok(w) } @@ -989,6 +1011,14 @@ mod msg_codes { code(rule(&[("input_tokens", &["200k"])])), "engine.compare.no_operator" ); + // 时间窗口写错是引擎带规则名的那一句,说的是哪个值 + let m = to_rule(&rule(&[("time", &["mon-fri 09:00-18:00", "9-5"])]), &c).unwrap_err(); + assert_eq!(m.code, "engine.rule_time_syntax"); + assert_eq!((m.arg("rule"), m.arg("value")), ("长上下文", "9-5")); + assert_eq!( + code(rule(&[("time", &[])])), + "control.rule.condition_no_value" + ); let mut r = rule(&[]); r.deny = Some("no".into()); assert_eq!(code(r), "control.rule.forward_and_deny"); diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index cefc9aac..ee8ba232 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -207,6 +207,53 @@ async fn the_mismatch_is_the_condition_that_really_failed() { assert_eq!(r.trace[0].mismatch.as_ref().unwrap().field.slug(), "model"); } +/// 时间条件按试算那一刻的本地时间看:没对上的说出条件写的窗口和此刻几点 +#[tokio::test] +async fn a_time_window_is_tried_against_now_and_the_mismatch_says_what_time_it_is() { + let (_d, app) = app(); + // 一个从现在起两小时后开始、三小时后结束的窗口:此刻一定不在里面(终点绕过午夜的 + // 是过夜的窗口,照样不含此刻) + let now = tw_engine::LocalTime::now(); + let days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + let hhmm = |m: u16| format!("{:02}:{:02}", m / 60 % 24, m % 60); + let later = format!( + "{} {}-{}", + days[now.weekday as usize], + hhmm((now.minute + 120) % 1440), + hhmm((now.minute + 180) % 1440) + ); + let draft = |window: &str| { + format!( + r#"{{"model":"m","draft":{{"name":"x","rules":[ + {{"name":"窗口","conditions":[{{"field":"time","values":["{window}"]}}],"to":"中转"}}, + {{"name":"兜底","to":"官方"}}]}}}}"# + ) + }; + let r = run(&app, &draft(&later)).await; + assert_eq!(r.trace[0].verdict.slug(), "skipped", "{:?}", r.trace[0]); + let m = r.trace[0].mismatch.as_ref().unwrap(); + assert_eq!(m.field.slug(), "time"); + assert_eq!(m.want, std::slice::from_ref(&later)); + // 实际的值是此刻,写成 `fri 17:30` + let (day, clock) = m.got.split_once(' ').unwrap(); + assert!(days.contains(&day), "{}", m.got); + assert_eq!((clock.len(), &clock[2..3]), (5, ":"), "{}", m.got); + assert_eq!(r.rule.as_deref(), Some("兜底")); + + // 全天的窗口此刻一定在里面 + let r = run(&app, &draft("00:00-24:00")).await; + assert_eq!(r.rule.as_deref(), Some("窗口")); + assert_eq!(r.candidates, ["中转"]); + + // 写错的窗口在求值之前就说出来,带规则名和那个值 + let (st, body) = send(&app, &draft("9-5")).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["code"], "engine.rule_time_syntax", "{body}"); + assert_eq!(v["args"]["rule"], "窗口", "{body}"); + assert_eq!(v["args"]["value"], "9-5", "{body}"); +} + #[tokio::test] async fn a_cached_request_is_pinned_to_the_official_upstream() { let (_d, app) = app(); diff --git a/crates/tw-engine/Cargo.toml b/crates/tw-engine/Cargo.toml index b5f6a48d..8a45c211 100644 --- a/crates/tw-engine/Cargo.toml +++ b/crates/tw-engine/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } tw-types = { workspace = true } tracing = { workspace = true } +chrono = { workspace = true } [dev-dependencies] serde_yaml_ng = { workspace = true } diff --git a/crates/tw-engine/src/engine.rs b/crates/tw-engine/src/engine.rs index 0946ee9a..b8bf476b 100644 --- a/crates/tw-engine/src/engine.rs +++ b/crates/tw-engine/src/engine.rs @@ -752,11 +752,27 @@ pub enum RouteError { PinnedWithProviderWouldBe(String), #[error("{}", self.msg())] PinnedWithSetModel(String), + /// 规则的 `time` 条件有一个值不合写法。**带规则名**:配置里有好几条规则时,只说 + /// 值的话用户得一条条找([`MatchError::BadTime`] 本身不认识规则名) + #[error("{}", self.msg())] + TimeSyntax { rule: String, value: String }, #[error(transparent)] Match(#[from] MatchError), } impl RouteError { + /// 规则 `rule` 的条件写错了。时间条件的那一句带上规则名([`RouteError::TimeSyntax`]); + /// 比较式的照旧是引擎的那几句(码不变,控制面在前面补规则名) + pub fn for_rule(rule: &str, e: MatchError) -> Self { + match e { + MatchError::BadTime { value, .. } => RouteError::TimeSyntax { + rule: rule.to_string(), + value, + }, + other => RouteError::Match(other), + } + } + /// 给人看的那句话,带码。 pub fn msg(&self) -> Msg { match self { @@ -860,6 +876,12 @@ impl RouteError { evaluated once an upstream is chosen, and the pinned list already chooses the \ upstreams" ), + RouteError::TimeSyntax { rule, value } => msg!( + "engine.rule_time_syntax", rule = rule, value = value => + "rule `{rule}`: time condition `{value}` is not written as `[days ]HH:MM-HH:MM`: \ + days are mon, tue, wed, thu, fri, sat, sun or a range like mon-fri, the hours run \ + from 00:00 to 24:00, as in \"mon-fri 09:00-18:00\"" + ), RouteError::PinnedWithSetModel(rule) => msg!( "engine.pinned_with_set_model", rule = rule => "rule `{rule}` pins models and also sets the model. A pinned model is sent as \ @@ -1150,7 +1172,9 @@ impl Engine { /// **还没保存的规则也用它查** —— 试算一份草稿之前,先说清楚哪条写错了。 pub fn check_rules(&self, rules: &[Rule]) -> Result<(), RouteError> { for r in rules { - r.when.validate()?; + r.when + .validate() + .map_err(|e| RouteError::for_rule(&r.name, e))?; // 指定模型的几条单独说:它和阶段二、和 `set.model` 同用时,泛泛的那句 // 说不清为什么不行 if let Some(pinned) = r.to.as_ref().and_then(Target::pinned) { @@ -3307,6 +3331,14 @@ mod msg_codes { }, RouteError::PinnedWithProviderWouldBe("r".into()), RouteError::PinnedWithSetModel("r".into()), + RouteError::TimeSyntax { + rule: "r".into(), + value: "9-5".into(), + }, + RouteError::Match(MatchError::BadTime { + value: "9-5".into(), + source: crate::time::TimeError::BadTimeRange("9-5".into()), + }), bad(ParseError::Empty), bad(ParseError::NoOperator("200k".into())), bad(ParseError::BadNumber(">x".into())), diff --git a/crates/tw-engine/src/facts.rs b/crates/tw-engine/src/facts.rs index 3458d231..6d2468d9 100644 --- a/crates/tw-engine/src/facts.rs +++ b/crates/tw-engine/src/facts.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; use tw_dialect::ir::{Part, Request, Role, ToolInput, ToolKind}; +use crate::time::LocalTime; + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RequestFacts { /// 客户端要的模型名(**不是我们要发给上游的那个**) @@ -33,6 +35,13 @@ pub struct RequestFacts { /// `from_request` 不会填它,网关在识别之后单独设。空字符串 /// 表示这是一个真实的用户请求。 pub intent: String, + /// 路由这一刻的本地时间(core 所在机器的时区)。 + /// + /// **从请求体里读不出来,由网关在路由时填**,试算填当下;测试和试算可以塞一个定死的 + /// 时刻。没填(`None`)时,带 `time` 条件的规则不命中 —— 当成某个固定时刻会让 + /// 规则悄悄在错的时候命中。 + #[serde(default)] + pub time: Option, } impl RequestFacts { @@ -61,6 +70,7 @@ impl RequestFacts { }), thinking: r.reasoning.as_ref().is_some_and(|x| x.enabled), stream: r.stream, + time: None, } } } diff --git a/crates/tw-engine/src/lib.rs b/crates/tw-engine/src/lib.rs index 8b0f65ee..3dd814f4 100644 --- a/crates/tw-engine/src/lib.rs +++ b/crates/tw-engine/src/lib.rs @@ -3,6 +3,7 @@ pub mod engine; pub mod facts; pub mod num; pub mod rule; +pub mod time; pub mod weighted; pub use catalog::{Catalog, ProviderModels}; @@ -13,4 +14,5 @@ pub use engine::{ order_by, scalar_name, }; pub use facts::{RequestFacts, TokenParts, estimate_parts, estimate_strings, estimate_tokens}; +pub use time::{LocalTime, Window}; pub use weighted::Member; diff --git a/crates/tw-engine/src/rule.rs b/crates/tw-engine/src/rule.rs index ad76ffb9..d49795bf 100644 --- a/crates/tw-engine/src/rule.rs +++ b/crates/tw-engine/src/rule.rs @@ -12,6 +12,7 @@ use tw_types::{Msg, msg}; use crate::facts::RequestFacts; use crate::num::Compare; +use crate::time::{LocalTime, TimeError, Window}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -48,6 +49,11 @@ pub struct When { /// 而不是规则本身。 #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, + /// 路由这一刻的本地时间落在哪个窗口里:`[ ]-` + /// (`mon-fri 09:00-18:00`、`sat,sun 00:00-24:00`、`22:00-06:00`,见 + /// [`crate::time`])。写几个窗口满足其一即可。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time: Option, /// **阶段二专用**:路由决定完之后,选中的是哪个 provider。 /// /// 它是个循环依赖 —— 规则的去向要在请求发出去之前定下来,而这个值要 @@ -74,6 +80,15 @@ impl OneOrMany { OneOrMany::Many(v) => v.iter().any(|x| x == s), } } + + /// 逐个值 + pub fn iter(&self) -> impl Iterator { + match self { + OneOrMany::One(x) => std::slice::from_ref(x).iter(), + OneOrMany::Many(v) => v.iter(), + } + .map(String::as_str) + } } impl When { @@ -91,6 +106,7 @@ impl When { && self.thinking.is_none() && self.stream.is_none() && self.intent.is_none() + && self.time.is_none() && self.provider_would_be.is_none() } @@ -139,9 +155,34 @@ impl When { .map_err(|e| MatchError::BadCompare { field, source: e })?; } } + self.windows()?; Ok(()) } + /// `time` 这一条写的那几个窗口。写错了说是哪个值 + fn windows(&self) -> Result, MatchError> { + self.time + .iter() + .flat_map(OneOrMany::iter) + .map(|v| { + crate::time::parse(v).map_err(|source| MatchError::BadTime { + value: v.to_string(), + source, + }) + }) + .collect() + } + + /// `time` 这一条对这一刻成立吗。没写这一条时成立;写了而这一刻不知道(`None`) + /// 时不成立。 + pub fn time_matches(&self, now: Option) -> Result { + if self.time.is_none() { + return Ok(true); + } + let windows = self.windows()?; + Ok(now.is_some_and(|t| windows.iter().any(|w| w.contains(t)))) + } + /// 这些条件对这个请求都成立吗。 /// /// `aliased`:请求的模型名是别名时它列的模型名([`crate::Engine::alias_models`]),不是 @@ -173,6 +214,9 @@ impl When { return Ok(false); } } + if !self.time_matches(f.time)? { + return Ok(false); + } for (field, spec, value) in [ ("input_tokens", &self.input_tokens, f.input_tokens as f64), ( @@ -217,6 +261,10 @@ pub enum MatchError { field: &'static str, source: crate::num::ParseError, }, + /// `time` 的一个值不合写法。`source` 说是哪儿错了,给日志和测试看;用户看到的 + /// 那句话把写法整个说一遍(见 [`crate::RouteError::TimeSyntax`],那一句带规则名) + #[error("{}", self.msg())] + BadTime { value: String, source: TimeError }, } impl MatchError { @@ -228,7 +276,17 @@ impl MatchError { /// 读配置文件时的报错走的是那一条。 pub fn msg(&self) -> Msg { use crate::num::ParseError; - let MatchError::BadCompare { field, source } = self; + let (field, source) = match self { + MatchError::BadCompare { field, source } => (field, source), + MatchError::BadTime { value, .. } => { + return msg!( + "engine.time_syntax", value = value => + "time condition `{value}` is not written as `[days ]HH:MM-HH:MM`: days are \ + mon, tue, wed, thu, fri, sat, sun or a range like mon-fri, the hours run from \ + 00:00 to 24:00, as in \"mon-fri 09:00-18:00\"" + ); + } + }; match source { ParseError::Empty => msg!( "engine.compare.empty", field = field => @@ -304,6 +362,7 @@ mod tests { image: false, thinking: false, stream: true, + time: None, } } @@ -509,6 +568,89 @@ mod tests { } } +#[cfg(test)] +mod time_tests { + use super::*; + + fn at(weekday: u8, hour: u16, minute: u16) -> RequestFacts { + RequestFacts { + time: Some(LocalTime::at(weekday, hour, minute)), + ..Default::default() + } + } + + fn w(y: &str) -> When { + serde_yaml_ng::from_str(y).unwrap() + } + + #[test] + fn office_hours_match_on_a_weekday_morning_and_not_on_a_weekend() { + let r = w("{ time: 'mon-fri 09:00-18:00' }"); + assert!(r.matches(&at(2, 10, 0), &[]).unwrap(), "周三上午十点"); + assert!(!r.matches(&at(5, 10, 0), &[]).unwrap(), "周六上午十点"); + assert!(!r.matches(&at(2, 18, 0), &[]).unwrap(), "终点不含"); + assert!(r.matches(&at(2, 9, 0), &[]).unwrap(), "起点含"); + } + + #[test] + fn several_windows_are_or_like_intent() { + let r = w("{ time: ['mon-fri 09:00-12:00', 'mon-fri 14:00-18:00'] }"); + assert!(r.matches(&at(0, 10, 0), &[]).unwrap()); + assert!(r.matches(&at(0, 15, 0), &[]).unwrap()); + assert!( + !r.matches(&at(0, 13, 0), &[]).unwrap(), + "午休不在任一窗口里" + ); + } + + #[test] + fn an_overnight_window_is_matched_on_the_next_morning() { + let r = w("{ time: 'fri 22:00-06:00' }"); + assert!(r.matches(&at(4, 23, 0), &[]).unwrap()); + assert!( + r.matches(&at(5, 5, 59), &[]).unwrap(), + "周六凌晨属于周五的窗口" + ); + assert!(!r.matches(&at(5, 6, 0), &[]).unwrap()); + } + + #[test] + fn an_unknown_time_never_matches_a_time_rule() { + // 网关没填这一刻的话,当成某个固定时刻会让规则在错的时候命中 + let r = w("{ time: '00:00-24:00' }"); + assert!(!r.matches(&RequestFacts::default(), &[]).unwrap()); + // 没写这一条的规则不在乎时间 + assert!( + When::default() + .matches(&RequestFacts::default(), &[]) + .unwrap() + ); + } + + #[test] + fn a_time_only_rule_is_not_a_catch_all() { + assert!(!w("{ time: '09:00-18:00' }").is_catch_all()); + } + + #[test] + fn a_malformed_window_is_caught_at_load_time_and_names_the_value() { + let r = w("{ time: ['mon-fri 09:00-18:00', '9-5'] }"); + let e = r.validate().unwrap_err(); + assert_eq!( + e, + MatchError::BadTime { + value: "9-5".into(), + source: TimeError::BadTime("9".into()), + } + ); + let m = e.msg(); + assert_eq!(m.code, "engine.time_syntax"); + assert_eq!(m.arg("value"), "9-5"); + // 求值时同样不会悄悄当成不命中 + assert!(r.matches(&at(0, 10, 0), &[]).is_err()); + } +} + #[cfg(test)] mod intent_tests { use super::*; diff --git a/crates/tw-engine/src/time.rs b/crates/tw-engine/src/time.rs new file mode 100644 index 00000000..f8f1c24b --- /dev/null +++ b/crates/tw-engine/src/time.rs @@ -0,0 +1,364 @@ +//! 时间条件:`[ ]-`,按 core 所在机器的本地时间。 +//! +//! 「工作日白天走公司的账号、夜里和周末走便宜的」是按时间分流的全部需求, +//! 所以一条写法只有一个窗口:哪几天、几点到几点。要几个窗口就写几条值, +//! 满足其一即可,和 `intent` 一样。 +//! +//! - 天:`mon,tue,wed,thu,fri,sat,sun`,用逗号列出,`mon-fri`、`sat-sun` 是一段, +//! `fri-mon` 跨过周末绕回去。不写天就是每天。大小写不论,存的时候是小写。 +//! - 时刻:24 小时制,起点含、终点不含,终点可以是 `24:00`。终点早于起点的是过夜的 +//! 窗口(`22:00-06:00` 是 22:00 到次日 05:59),**天指的是窗口开始的那一天**。 +//! +//! **按本地时间**:用户说「九点到六点」,说的是他墙上的钟,不是 UTC。 + +use std::fmt; + +/// 一个时刻:星期几、从零点起过了多少分钟。本地时间。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LocalTime { + /// 0 = 周一 … 6 = 周日 + pub weekday: u8, + /// 从零点起过了多少分钟,0 到 1439 + pub minute: u16, +} + +const DAYS: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + +impl LocalTime { + /// 此刻,按 core 所在机器的时区。 + pub fn now() -> Self { + Self::from(chrono::Local::now()) + } + + /// 某一天、某一刻。`weekday` 0 = 周一;超出范围的按 7 取余、分钟封顶到 23:59。 + pub fn at(weekday: u8, hour: u16, minute: u16) -> Self { + Self { + weekday: weekday % 7, + minute: (hour * 60 + minute).min(24 * 60 - 1), + } + } +} + +impl From> for LocalTime { + fn from(t: chrono::DateTime) -> Self { + use chrono::{Datelike, Timelike}; + Self { + weekday: t.weekday().num_days_from_monday() as u8, + minute: (t.hour() * 60 + t.minute()) as u16, + } + } +} + +/// 写出来是 `fri 17:30`:试算里「实际的值」用它,和条件的写法对得上 +impl fmt::Display for LocalTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {:02}:{:02}", + DAYS[(self.weekday % 7) as usize], + self.minute / 60, + self.minute % 60 + ) + } +} + +/// 一个时间窗口:哪几天的几点到几点。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Window { + /// 位 0 = 周一 … 位 6 = 周日。没写天的是全满 + pub days: u8, + /// 起点,从零点起的分钟数,含 + pub start: u16, + /// 终点,从零点起的分钟数,不含;`24:00` 是 1440。小于等于起点的是过夜的窗口 + pub end: u16, +} + +const ALL_DAYS: u8 = 0b111_1111; + +impl Window { + /// 这一刻在窗口里吗。 + /// + /// 过夜的窗口分两段看:起点那一天从起点到午夜,和**下一天**从零点到终点 —— + /// 周五 `22:00-06:00` 管到周六早上六点,周六自己不用在天的列表里。 + pub fn contains(&self, t: LocalTime) -> bool { + let day = t.weekday % 7; + let on = |d: u8| self.days & (1 << d) != 0; + if self.end > self.start { + return on(day) && t.minute >= self.start && t.minute < self.end; + } + let yesterday = (day + 6) % 7; + (on(day) && t.minute >= self.start) || (on(yesterday) && t.minute < self.end) + } +} + +/// 写法错在哪儿。 +/// +/// 对外只有一句话(`engine.rule_time_syntax`,见 [`crate::RouteError`]):这几种原因是给 +/// 测试和日志分辨用的,用户看到的那句话把整个写法说一遍更管用。 +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TimeError { + #[error("the value is empty")] + Empty, + #[error("`{0}` is not a day; days are mon, tue, wed, thu, fri, sat, sun")] + BadDay(String), + #[error("`{0}` is not a day range like mon-fri")] + BadDayRange(String), + #[error("`{0}` is not a time of day written as HH:MM")] + BadTime(String), + #[error("`{0}` is not a time range written as HH:MM-HH:MM")] + BadTimeRange(String), + #[error("the window starts and ends at the same minute")] + EmptyWindow, + #[error("more than a day list and a time range")] + TooManyParts, +} + +/// 解析 `[ ]-`。 +/// +/// 纯函数,大小写不论。多余的空白容忍(首尾、天和时间之间可以多个),天之间的逗号和 +/// 时间的连字符旁边不能有空白 —— 一个窗口一个写法,才能照原样回填到界面上。 +pub fn parse(s: &str) -> Result { + let lower = s.trim().to_ascii_lowercase(); + if lower.is_empty() { + return Err(TimeError::Empty); + } + let mut parts = lower.split_whitespace(); + let first = parts.next().ok_or(TimeError::Empty)?; + let (days, times) = match parts.next() { + None => (ALL_DAYS, first), + Some(second) => { + if parts.next().is_some() { + return Err(TimeError::TooManyParts); + } + (parse_days(first)?, second) + } + }; + let (a, b) = times + .split_once('-') + .ok_or_else(|| TimeError::BadTimeRange(times.to_string()))?; + let start = parse_clock(a)?; + let end = parse_clock(b)?; + // 起点不能是 24:00:那一分钟不存在。终点是 24:00 时是「到这一天结束」 + if start >= 24 * 60 { + return Err(TimeError::BadTime(a.to_string())); + } + if start == end { + return Err(TimeError::EmptyWindow); + } + Ok(Window { days, start, end }) +} + +/// `mon,wed,fri-sun` → 位图。 +fn parse_days(s: &str) -> Result { + let mut days = 0u8; + for item in s.split(',') { + match item.split_once('-') { + None => days |= 1 << day_index(item)?, + Some((from, to)) => { + if from.is_empty() || to.is_empty() { + return Err(TimeError::BadDayRange(item.to_string())); + } + let (from, to) = (day_index(from)?, day_index(to)?); + // `fri-mon` 绕过周日回到周一:从起点一天天走到终点为止 + let mut d = from; + loop { + days |= 1 << d; + if d == to { + break; + } + d = (d + 1) % 7; + } + } + } + } + Ok(days) +} + +fn day_index(s: &str) -> Result { + DAYS.iter() + .position(|d| *d == s) + .map(|i| i as u8) + .ok_or_else(|| TimeError::BadDay(s.to_string())) +} + +/// `HH:MM` → 分钟数。`24:00` 读成 1440,由调用方决定允不允许。 +fn parse_clock(s: &str) -> Result { + let bad = || TimeError::BadTime(s.to_string()); + let (h, m) = s.split_once(':').ok_or_else(bad)?; + if h.len() != 2 || m.len() != 2 || !h.bytes().chain(m.bytes()).all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + let (h, m): (u16, u16) = (h.parse().map_err(|_| bad())?, m.parse().map_err(|_| bad())?); + if m >= 60 || h > 24 || (h == 24 && m != 0) { + return Err(bad()); + } + Ok(h * 60 + m) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(s: &str) -> Window { + parse(s).unwrap_or_else(|e| panic!("`{s}`: {e}")) + } + + fn at(day: &str, hhmm: &str) -> LocalTime { + let (h, m) = hhmm.split_once(':').unwrap(); + LocalTime::at( + day_index(day).unwrap(), + h.parse().unwrap(), + m.parse().unwrap(), + ) + } + + #[test] + fn the_three_examples_from_the_manual_parse() { + assert_eq!( + w("mon-fri 09:00-18:00"), + Window { + days: 0b001_1111, + start: 9 * 60, + end: 18 * 60 + } + ); + assert_eq!( + w("sat,sun 00:00-24:00"), + Window { + days: 0b110_0000, + start: 0, + end: 24 * 60 + } + ); + assert_eq!( + w("22:00-06:00"), + Window { + days: ALL_DAYS, + start: 22 * 60, + end: 6 * 60 + } + ); + } + + #[test] + fn day_lists_mix_single_days_and_ranges() { + assert_eq!(w("mon,wed,fri 09:00-10:00").days, 0b001_0101); + assert_eq!(w("mon,thu-sat 09:00-10:00").days, 0b011_1001); + assert_eq!(w("sun 09:00-10:00").days, 0b100_0000); + // 同一天写两遍只是多余,不是错 + assert_eq!(w("mon,mon 09:00-10:00").days, 0b000_0001); + } + + #[test] + fn a_day_range_may_wrap_past_sunday() { + // 周五到周一:五、六、日、一 + assert_eq!(w("fri-mon 09:00-10:00").days, 0b111_0001); + assert_eq!(w("sun-mon 09:00-10:00").days, 0b100_0001); + // 起点就是终点:只有那一天 + assert_eq!(w("wed-wed 09:00-10:00").days, 0b000_0100); + } + + #[test] + fn input_is_case_insensitive_and_tolerates_outer_whitespace() { + assert_eq!(w(" Mon-FRI 09:00-18:00 "), w("mon-fri 09:00-18:00")); + assert_eq!(w("SAT,Sun 00:00-24:00"), w("sat,sun 00:00-24:00")); + } + + #[test] + fn malformed_values_are_refused() { + for (bad, why) in [ + ("", TimeError::Empty), + (" ", TimeError::Empty), + ("mon-fri", TimeError::BadTime("mon".into())), + ("09:00", TimeError::BadTimeRange("09:00".into())), + ("9:00-18:00", TimeError::BadTime("9:00".into())), + ("09:00-18", TimeError::BadTime("18".into())), + ("09:60-18:00", TimeError::BadTime("09:60".into())), + ("25:00-26:00", TimeError::BadTime("25:00".into())), + ("24:00-06:00", TimeError::BadTime("24:00".into())), + ("09:00-24:30", TimeError::BadTime("24:30".into())), + ("09:00-09:00", TimeError::EmptyWindow), + ("monday 09:00-18:00", TimeError::BadDay("monday".into())), + ("mon- 09:00-18:00", TimeError::BadDayRange("mon-".into())), + ("mon,,fri 09:00-18:00", TimeError::BadDay("".into())), + ("mon fri 09:00-18:00", TimeError::TooManyParts), + ("09:00 - 18:00", TimeError::TooManyParts), + ("mon-fri 09:00-18:00 extra", TimeError::TooManyParts), + ] { + assert_eq!(parse(bad), Err(why), "`{bad}`"); + } + } + + #[test] + fn start_is_inclusive_and_end_is_exclusive() { + let office = w("mon-fri 09:00-18:00"); + assert!(office.contains(at("mon", "09:00"))); + assert!(office.contains(at("fri", "17:59"))); + assert!(!office.contains(at("mon", "08:59"))); + assert!(!office.contains(at("fri", "18:00"))); + assert!(!office.contains(at("sat", "12:00"))); + } + + #[test] + fn midnight_as_the_end_covers_the_last_minute_of_the_day() { + let weekend = w("sat,sun 00:00-24:00"); + assert!(weekend.contains(at("sat", "00:00"))); + assert!(weekend.contains(at("sun", "23:59"))); + assert!(!weekend.contains(at("mon", "00:00"))); + assert!(!weekend.contains(at("fri", "23:59"))); + } + + #[test] + fn an_overnight_window_belongs_to_the_day_it_starts() { + let night = w("fri 22:00-06:00"); + assert!(night.contains(at("fri", "22:00"))); + assert!(night.contains(at("fri", "23:59"))); + assert!(night.contains(at("sat", "00:00"))); + assert!(night.contains(at("sat", "05:59"))); + assert!(!night.contains(at("sat", "06:00"))); + assert!( + !night.contains(at("sat", "22:00")), + "周六晚上不在周五的窗口里" + ); + assert!(!night.contains(at("fri", "21:59"))); + assert!( + !night.contains(at("fri", "05:00")), + "周五凌晨属于周四的窗口" + ); + // 每天的过夜窗口:周日晚绕到周一早上 + let every = w("22:00-06:00"); + assert!(every.contains(at("sun", "23:00"))); + assert!(every.contains(at("mon", "05:00"))); + assert!(!every.contains(at("mon", "12:00"))); + } + + #[test] + fn an_overnight_window_ending_at_midnight_stops_at_the_day_end() { + // 终点 00:00 早于起点:过夜的写法,实际只到这一天的 23:59 + let late = w("mon 22:00-00:00"); + assert!(late.contains(at("mon", "23:59"))); + assert!(!late.contains(at("tue", "00:00"))); + } + + #[test] + fn a_local_time_prints_like_a_condition_value() { + assert_eq!(at("fri", "17:30").to_string(), "fri 17:30"); + assert_eq!(at("mon", "00:05").to_string(), "mon 00:05"); + } + + #[test] + fn a_local_time_comes_from_a_chrono_date_in_its_own_zone() { + use chrono::TimeZone; + // 2026-10-10 是周六 + let t = chrono::FixedOffset::east_opt(8 * 3600) + .unwrap() + .with_ymd_and_hms(2026, 10, 10, 9, 5, 0) + .unwrap(); + assert_eq!(LocalTime::from(t), at("sat", "09:05")); + // 同一瞬间在 UTC 还是周六凌晨一点多 + assert_eq!( + LocalTime::from(t.with_timezone(&chrono::Utc)), + at("sat", "01:05") + ); + } +} diff --git a/crates/tw-gateway/src/lib.rs b/crates/tw-gateway/src/lib.rs index 9bdf4d7d..98407c8d 100644 --- a/crates/tw-gateway/src/lib.rs +++ b/crates/tw-gateway/src/lib.rs @@ -74,7 +74,7 @@ pub use probe::{ModelList, ProbeResult, probe, probe_bedrock}; pub use quote::Quote; pub use server::{router, serve}; pub use state::credential_failed; -pub use state::{AppState, Runtime}; +pub use state::{AppState, LocalClock, Runtime}; /// Anthropic 流里上游静默多久补一个 `ping`。 /// diff --git a/crates/tw-gateway/src/server/pipeline.rs b/crates/tw-gateway/src/server/pipeline.rs index d751c680..95965d43 100644 --- a/crates/tw-gateway/src/server/pipeline.rs +++ b/crates/tw-gateway/src/server/pipeline.rs @@ -116,7 +116,9 @@ pub(super) async fn pipeline( // 管线第 2 步:读出路由事实,路由。**看的是客户端的原话**:插件的请求钩子排在路由 // 之后(每发往一个上游跑一次,见 `plug`),左右不了请求去哪一家 - let (reading, fp) = heavy(&req.body, || read(&req, intent, parsed.as_ref())); + let (reading, fp) = heavy(&req.body, || { + read(&req, intent, parsed.as_ref(), (state.local_time)()) + }); let conv = conversation(&rt, &req, &reading, fp.as_deref()); let (choice, decision) = match route(&state, &rt, &req, &reading, conv.as_ref())? { Routed::Go(choice, decision) => (choice, decision), @@ -506,10 +508,13 @@ fn read( req: &Inbound, intent: String, parsed: Option<&serde_json::Value>, + now: tw_engine::LocalTime, ) -> (crate::client_api::Reading, Option) { let mut reading = crate::client_api::read(req.uri.path(), req.query.as_deref(), parsed); reading.facts.client = req.client_name.clone(); reading.facts.intent = intent; + // 规则的 `time` 条件按路由这一刻看,不是请求体里的东西 + reading.facts.time = Some(now); reading.harness = tw_dialect::harness::detect( req.headers .get(axum::http::header::USER_AGENT) diff --git a/crates/tw-gateway/src/server/upgrade.rs b/crates/tw-gateway/src/server/upgrade.rs index aafd5241..bc866f34 100644 --- a/crates/tw-gateway/src/server/upgrade.rs +++ b/crates/tw-gateway/src/server/upgrade.rs @@ -48,6 +48,7 @@ pub(super) async fn ws_upgrade( .then(|| crate::ws::query_model(query.as_deref())) .flatten() .unwrap_or_default(), + time: Some((state.local_time)()), ..Default::default() }; let route = rt.engine.route_of(&client_name).to_string(); @@ -133,6 +134,7 @@ pub(super) async fn ws_upgrade( provider: provider.clone(), client: client_name.clone(), path: uri.path().to_string(), + local_time: state.local_time.clone(), }; let (alive, _) = state.health.filter(&decision.candidates); // 升级请求带着模型的(Realtime):挑头一家连得上的 —— 别名对得上、要发给它的名字密钥 diff --git a/crates/tw-gateway/src/state.rs b/crates/tw-gateway/src/state.rs index 2d258561..5ffe8cfb 100644 --- a/crates/tw-gateway/src/state.rs +++ b/crates/tw-gateway/src/state.rs @@ -123,8 +123,14 @@ impl Runtime { } } +/// 路由这一刻的本地时间从哪儿来:规则的 `time` 条件按它求值。 +pub type LocalClock = Arc tw_engine::LocalTime + Send + Sync>; + #[derive(Clone)] pub struct AppState { + /// 路由这一刻的本地时间(规则的 `time` 条件按它求值)。默认是系统时钟、本机时区; + /// 测试把它定死([`Self::set_local_time`])。**跨重载存活**:它不属于配置 + pub(crate) local_time: LocalClock, /// 配置换入时整块换掉的那部分(第 ⑤ 步)。 /// /// **一次 `store` 就是一次生效**:正在跑的请求持有旧的 `Arc`,跑完 @@ -286,6 +292,7 @@ impl AppState { let key_limits = Arc::new(crate::key_limits::KeyLimits::new(bus.clone())); key_limits.configure(&rt.config); let state = Self { + local_time: Arc::new(tw_engine::LocalTime::now), rt: Arc::new(arc_swap::ArcSwap::from_pointee(rt)), gate: Default::default(), slots, @@ -368,6 +375,12 @@ impl AppState { self.rt.load().config.clone() } + /// 换一个路由看的本地时钟(测试把时间定在某个工作日的上午、某个周末)。只在测试里、 + /// 第一个请求之前调 + pub fn set_local_time(&mut self, clock: LocalClock) { + self.local_time = clock; + } + /// 换一个看用量上限的时钟(测试把时间拨到零点前后)。**账从空的开始**:只在测试里、 /// 第一个请求之前调 pub fn set_key_limits_clock(&mut self, clock: Arc) { diff --git a/crates/tw-gateway/src/ws.rs b/crates/tw-gateway/src/ws.rs index dd7faade..df2dbbf1 100644 --- a/crates/tw-gateway/src/ws.rs +++ b/crates/tw-gateway/src/ws.rs @@ -265,6 +265,8 @@ pub struct Naming { pub client: String, /// 升级的路径。一帧按这条路径上的一次请求读([`crate::client_api::read`]) pub path: String, + /// 一帧路由这一刻的本地时间从哪儿来(规则的 `time` 条件) + pub local_time: crate::state::LocalClock, } /// 一帧 `response.create` 发出去的样子([`Naming::frame`])。 @@ -418,6 +420,7 @@ impl Naming { fn read(&self, frame: &serde_json::Value) -> crate::client_api::Reading { let mut reading = crate::client_api::read(&self.path, None, Some(frame)); reading.facts.client = self.client.clone(); + reading.facts.time = Some((self.local_time)()); reading } diff --git a/crates/tw-gateway/tests/time_window.rs b/crates/tw-gateway/tests/time_window.rs new file mode 100644 index 00000000..eb2d0590 --- /dev/null +++ b/crates/tw-gateway/tests/time_window.rs @@ -0,0 +1,109 @@ +//! 按时间分流:规则的 `time` 条件按路由那一刻 core 的本地时间求值。 +//! +//! 时钟是定死的([`tw_gateway::AppState::set_local_time`]):同一份配置,周三上午和 +//! 周六上午各起一个网关,同一个请求走到不同的上游。 + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use axum::routing::any; +use tw_config::{Client, Config, Provider}; +use tw_engine::{LocalTime, RouteSet, Rule}; + +/// 一家上游,回答里带着自己的名字 +async fn upstream(name: &'static str) -> SocketAddr { + let app = Router::new().fallback(any(move || async move { + axum::response::Response::builder() + .header("content-type", "application/json") + .body(axum::body::Body::from(format!( + r#"{{"type":"message","role":"assistant","model":"m","content":[{{"type":"text","text":"{name}"}}],"usage":{{"input_tokens":3,"output_tokens":1}}}}"# + ))) + .unwrap() + })); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let a = l.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + a +} + +fn provider(name: &str, up: SocketAddr) -> Provider { + Provider { + name: name.into(), + base_url: format!("http://{up}"), + key: Some("sk-x".into()), + protocol: Some(tw_config::Protocol::Anthropic), + ..Default::default() + } +} + +fn rule(name: &str, when: &str, to: &str) -> Rule { + Rule { + name: name.into(), + when: serde_yaml_ng::from_str(when).unwrap(), + to: Some(to.into()), + set: None, + deny: None, + } +} + +fn cfg(day: SocketAddr, night: SocketAddr) -> Config { + Config { + clients: vec![Client { + name: "我".into(), + key: "tw-k".into(), + ..Default::default() + }], + providers: vec![provider("day", day), provider("night", night)], + routes: vec![RouteSet::default_with(vec![ + rule("工作时间", "{ time: 'mon-fri 09:00-18:00' }", "day"), + rule("其余", "{}", "night"), + ])], + ..Default::default() + } +} + +/// 起一个网关,路由看到的本地时间定在 `at` +async fn serve(cfg: Config, at: LocalTime) -> SocketAddr { + let mut state = tw_gateway::AppState::new(cfg).unwrap(); + state.set_local_time(Arc::new(move || at)); + let addr = tw_gateway::serve(state, ([127, 0, 0, 1], 0).into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + addr +} + +/// 发一个请求,返回回答的正文 +async fn ask(gw: SocketAddr) -> String { + let r = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .post(format!("http://{gw}/v1/messages")) + .header("x-api-key", "tw-k") + .body(r#"{"model":"m","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}"#) + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 200); + r.text().await.unwrap() +} + +#[tokio::test] +async fn the_same_request_goes_elsewhere_outside_office_hours() { + let (day, night) = (upstream("day").await, upstream("night").await); + // 周三 10:00:工作时间,走 day + let gw = serve(cfg(day, night), LocalTime::at(2, 10, 0)).await; + let body = ask(gw).await; + assert!(body.contains(r#""text":"day""#), "{body}"); + // 周六 10:00:不在工作时间,走 night + let gw = serve(cfg(day, night), LocalTime::at(5, 10, 0)).await; + let body = ask(gw).await; + assert!(body.contains(r#""text":"night""#), "{body}"); + // 周三 18:00:终点不含,走 night + let gw = serve(cfg(day, night), LocalTime::at(2, 18, 0)).await; + let body = ask(gw).await; + assert!(body.contains(r#""text":"night""#), "{body}"); +} diff --git a/docs/config.md b/docs/config.md index 5775a5ae..fac9474c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1319,6 +1319,7 @@ routes: | `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`). Classes set to `intercept` in `client_probes` are answered locally and never reach routing. | +| `time` | string or list of strings | — | Local time of the machine core runs on, as a window `[days ]HH:MM-HH:MM`: `mon-fri 09:00-18:00`, `sat,sun 00:00-24:00`, `22:00-06:00`. Several windows: any of them. See below. | | `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`. | @@ -1326,6 +1327,17 @@ A comparison starts with `>`, `>=`, `<`, `<=` or `==`, and the number may end in `k` or `m`: `">200k"`, `"<=4k"`. Without an operator it is an error, not an equality: `"200k"` alone is refused. +A time window is `[ ]-`, evaluated against the local +wall-clock time of the machine core runs on, at the moment the request is +routed. `` is a comma-separated list of `mon`, `tue`, `wed`, `thu`, +`fri`, `sat`, `sun` and ranges such as `mon-fri` or `fri-mon` (which wraps +past Sunday); leave it out for every day. Case does not matter. The hours are +24-hour: the start is included, the end is not, and the end may be `24:00`. +An end earlier than the start is an overnight window, and its days are the +days it starts on: `fri 22:00-06:00` runs from Friday 22:00 to Saturday 05:59. +A value that does not fit this shape is refused when the configuration is +read, naming the rule and the value. + #### `routes[].rules[].set` `set.model` may name an alias; each upstream then receives its own name for diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index bb985241..94b7cf7b 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -987,11 +987,14 @@ routes: | `thinking` | 布尔 | — | 是否开启扩展思考。 | | `stream` | 布尔 | — | 是否流式返回。 | | `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。在 `client_probes` 中设为 `intercept` 的类别由本地应答,不会进入路由。 | +| `time` | 字符串或字符串列表 | — | core 所在机器的本地时间落在窗口 `[days ]HH:MM-HH:MM` 里:`mon-fri 09:00-18:00`、`sat,sun 00:00-24:00`、`22:00-06:00`。写几个窗口满足其一即可。见下文。 | | `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 | 比较式以 `>`、`>=`、`<`、`<=` 或 `==` 开头,数字可以带 `k` 或 `m` 后缀:`">200k"`、`"<=4k"`。不带运算符是错误,不当作相等:单写 `"200k"` 会被拒绝。 +时间窗口写成 `[ ]-`,按 core 所在机器在路由那一刻的本地时间求值。`` 是用逗号分隔的 `mon`、`tue`、`wed`、`thu`、`fri`、`sat`、`sun`,也可以写 `mon-fri`、`fri-mon`(跨过周日绕回去)这样的一段;不写就是每天。大小写不论。时刻是 24 小时制:起点含、终点不含,终点可以写 `24:00`。终点早于起点的是过夜的窗口,天指窗口开始的那一天:`fri 22:00-06:00` 从周五 22:00 到周六 05:59。不合这个写法的值在读配置时就被拒绝,并说明是哪条规则、哪个值。 + #### `routes[].rules[].set` `set.model` 可以写别名,每家上游收到各自对它的叫法。和别名一样,回答里的模型名写成客户端请求的名称。