Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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<String>,
}

Expand Down Expand Up @@ -5376,9 +5387,10 @@ pub struct RuleTrace {
pub struct MismatchView {
/// 和 `ConditionView.field` 同一个词表
pub field: ConditionField,
/// 规则里写的值。`intent` 写了多个时逐个列出
/// 规则里写的值。`intent`、`time` 写了多个时逐个列出
pub want: Vec<String>,
/// 这个请求实际的值。`intent` 为空表示真实的用户请求
/// 这个请求实际的值。`intent` 为空表示真实的用户请求;`time` 是试算那一刻 core 的
/// 本地时间,写成 `fri 17:30`
pub got: String,
}

Expand Down
16 changes: 16 additions & 0 deletions crates/tw-config/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()),
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 的原话翻不了,外面那一层(哪一关、第几行)带码
Expand Down
9 changes: 9 additions & 0 deletions crates/tw-config/tests/manual/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,15 @@ pub fn sections() -> Vec<Section> {
"客户端的辅助请求:`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,
Expand Down
13 changes: 13 additions & 0 deletions crates/tw-control/src/dryrun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}

Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion crates/tw-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,9 @@ pub(crate) fn describe_when(w: &tw_engine::rule::When) -> Vec<tw_api::ConditionV
if let Some(i) = &w.intent {
push(ConditionField::Intent, one_or_many(i));
}
if let Some(t) = &w.time {
push(ConditionField::Time, one_or_many(t));
}
// **不写出来的话,一条只有 provider_would_be 的规则在界面上会显示成
// 「兜底」**(条件为空就是兜底的标记)—— 那是个会让人查半天的假象。
if let Some(p) = &w.provider_would_be {
Expand Down Expand Up @@ -2296,7 +2299,7 @@ mod describe_tests {
"{ model: a*, client: c, dialect: anthropic, input_tokens: '>1k',
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() {
Expand Down
40 changes: 35 additions & 5 deletions crates/tw-control/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,22 @@ fn when_from(
)
})?)
}
F::Time => {
// 大小写不论,存小写:界面回填时按一种写法读
let lower: Vec<String> = 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),
Expand All @@ -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)
}
Expand Down Expand Up @@ -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");
Expand Down
47 changes: 47 additions & 0 deletions crates/tw-control/tests/dryrun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/tw-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
34 changes: 33 additions & 1 deletion crates/tw-engine/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())),
Expand Down
10 changes: 10 additions & 0 deletions crates/tw-engine/src/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/// 客户端要的模型名(**不是我们要发给上游的那个**)
Expand All @@ -33,6 +35,13 @@ pub struct RequestFacts {
/// `from_request` 不会填它,网关在识别之后单独设。空字符串
/// 表示这是一个真实的用户请求。
pub intent: String,
/// 路由这一刻的本地时间(core 所在机器的时区)。
///
/// **从请求体里读不出来,由网关在路由时填**,试算填当下;测试和试算可以塞一个定死的
/// 时刻。没填(`None`)时,带 `time` 条件的规则不命中 —— 当成某个固定时刻会让
/// 规则悄悄在错的时候命中。
#[serde(default)]
pub time: Option<LocalTime>,
}

impl RequestFacts {
Expand Down Expand Up @@ -61,6 +70,7 @@ impl RequestFacts {
}),
thinking: r.reasoning.as_ref().is_some_and(|x| x.enabled),
stream: r.stream,
time: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/tw-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Loading
Loading