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
7 changes: 4 additions & 3 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,9 +1381,10 @@ pub enum Event {
/// 握手有了结果就发;Responses 连接上的一轮,上游这一轮的第一帧到了就发(没等到就在
/// 结局之前),那一跳是这条连接连着的那一家。
///
/// **规则做了决定、请求却一家上游都没到的也发**:规则拒绝了它(第一阶段),
/// 或者规则选中的上游都服务不了这个模型 —— 那时尝试链是空的,紧跟着一条
/// 失败。每条规则命中了多少请求要数得到它们(见 `GET /summary/routes`)。
/// **认出了密钥、请求却一家上游都没到的也发**:规则拒绝了它(第一阶段),模型准入
/// 没过(模型名没有上游提供、密钥不让用),规则选中的上游都服务不了这个模型,没有一条
/// 规则命中,还没配上游 —— 那时尝试链是空的,紧跟着一条失败。每条规则命中了多少请求
/// 要数得到它们(见 `GET /summary/routes`);规则还没做出决定的,`rule` 是空的。
///
/// 带着的是这次请求**完整的路由记录**,落库的就是它([`RoutingView`]):开始
/// 事件里那几项(路由、规则、策略组、改写)在这里再给一遍,是这一份的终稿 ——
Expand Down
146 changes: 121 additions & 25 deletions crates/tw-control/tests/route_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,19 @@ async fn ask(gw: SocketAddr, model: &str) -> u16 {
.as_u16()
}

#[tokio::test]
async fn every_route_and_rule_counts_what_went_through_it_including_denials() {
/// 真网关、真存储层、控制面,和 `twcore` 一样接:网关的事件经总线落库
struct World {
_dir: tempfile::TempDir,
gw: SocketAddr,
store: Arc<tokio::sync::Mutex<tw_store::Recorder>>,
app: axum::Router,
}

async fn world(text: &str) -> World {
let d = tempfile::tempdir().unwrap();
let text = yaml(upstream().await);
let p = d.path().join("config.yaml");
std::fs::write(&p, &text).unwrap();
let cfg: tw_config::Config = serde_yaml_ng::from_str(&text).unwrap();
std::fs::write(&p, text).unwrap();
let cfg: tw_config::Config = serde_yaml_ng::from_str(text).unwrap();
let gw = tw_gateway::AppState::new(cfg).unwrap();
// 和 twcore 一样:存储层从总线给它的那条通道收事件,落库
let (_bodies, rx) = tokio::sync::mpsc::channel(8);
Expand Down Expand Up @@ -113,33 +119,56 @@ async fn every_route_and_rule_counts_what_went_through_it_including_denials() {
let addr = tw_gateway::serve(gw, ([127, 0, 0, 1], 0).into())
.await
.unwrap();
World {
_dir: d,
gw: addr,
store,
app,
}
}

impl World {
/// 落库在另一个任务上:等 `n` 行都写下
async fn landed(&self, n: i64) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while self.store.lock().await.db().count().unwrap() < n {
assert!(std::time::Instant::now() < deadline, "{n} 个请求没有都落库");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}

async fn get(&self, path: &str) -> serde_json::Value {
let resp = self
.app
.clone()
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&body).unwrap()
}
}

#[tokio::test]
async fn every_route_and_rule_counts_what_went_through_it_including_denials() {
let w = world(&yaml(upstream().await)).await;
let addr = w.gw;

let before = now_ms();
assert_eq!(ask(addr, "claude-sonnet-4-5").await, 200);
assert_eq!(ask(addr, "claude-haiku-4-5").await, 200);
assert_eq!(ask(addr, "claude-opus-4-1").await, 403, "该被规则拒绝");

// 落库在另一个任务上:等三行都写下
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while store.lock().await.db().count().unwrap() < 3 {
assert!(std::time::Instant::now() < deadline, "三个请求没有都落库");
tokio::time::sleep(Duration::from_millis(20)).await;
}
w.landed(3).await;

// 窗口的右端给到将来:缺省是「此刻」,而最后那个请求可能就开始在这一毫秒
let resp = app
.oneshot(
Request::get("/summary/routes?from_ms=0&to_ms=99999999999999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let stats: tw_api::RouteStats = serde_json::from_slice(&body).unwrap();
let stats: tw_api::RouteStats = serde_json::from_value(
w.get("/summary/routes?from_ms=0&to_ms=99999999999999")
.await,
)
.unwrap();
// 库是这个测试里新建的:记录从第一个请求开始,不是问的起点(0)
let covered = stats.covered_since_ms.expect("库里有请求,记录有起点");
assert!(
Expand Down Expand Up @@ -171,6 +200,73 @@ async fn every_route_and_rule_counts_what_went_through_it_including_denials() {
assert_eq!(rule("从没命中过"), None, "没命中过的规则不在里面");
}

/// 认出了密钥、却一家上游都没去的请求**落进请求库**:密钥不让用的模型(准入没过)、没有一条
/// 规则命中的。流量里那一行的模型是客户端写的、错误是告诉客户端的那一句;路由的统计数得到
/// 它们,规则没做出决定的只算进路由,不冒出一条没有名字的规则
#[tokio::test]
async fn a_request_refused_before_any_upstream_is_a_row_with_the_model_the_client_asked_for() {
let up = upstream().await;
let w = world(&format!(
r#"version: 1
listen:
control:
key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00
clients:
- name: 我
key: tw-k
route: 工作
allow: [claude-sonnet-*]
providers:
- name: up
base_url: http://{up}
key: sk-x
protocol: anthropic
routes:
- name: 工作
rules:
- name: 只走 claude
when: {{ model: claude-* }}
to: up
"#
))
.await;

assert_eq!(ask(w.gw, "claude-opus-4-1").await, 400, "密钥不让用");
assert_eq!(ask(w.gw, "gpt-5").await, 500, "没有一条规则命中");
w.landed(2).await;

let history = w.get("/history?limit=10").await;
let row = |model: &str| {
history
.as_array()
.unwrap()
.iter()
.find(|r| r["model"] == model)
.unwrap_or_else(|| panic!("流量里没有 {model}:{history}"))
.clone()
};
let barred = row("claude-opus-4-1");
assert_eq!(barred["error"]["code"], "gw.model.not_allowed", "{barred}");
assert_eq!(barred["error"]["args"]["model"], "claude-opus-4-1");
assert_eq!(barred["client"], "我");
assert_eq!(barred["routing"]["rule"], "只走 claude", "{barred}");
assert_eq!(barred["routing"]["attempts"], serde_json::json!([]));
let unrouted = row("gpt-5");
assert_eq!(unrouted["error"]["code"], "gw.route.failed", "{unrouted}");
assert_eq!(unrouted["routing"]["route"], "工作", "{unrouted}");
assert_eq!(unrouted["routing"]["rule"], "", "{unrouted}");

let stats: tw_api::RouteStats = serde_json::from_value(
w.get("/summary/routes?from_ms=0&to_ms=99999999999999")
.await,
)
.unwrap();
let work = &stats.routes[0];
assert_eq!((work.requests, work.failed), (2, 2), "{work:?}");
let names: Vec<&str> = work.rules.iter().map(|h| h.rule.as_str()).collect();
assert_eq!(names, ["只走 claude"], "{work:?}");
}

/// 还没有一条记录的库:线上的起点是 null,界面不会把空表读成「一周都没命中」。
#[tokio::test]
async fn a_store_with_no_history_yet_says_nothing_is_covered() {
Expand Down
9 changes: 5 additions & 4 deletions crates/tw-gateway/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,11 +344,12 @@ pub(crate) struct Choice {
pub(crate) stayed_on: Option<String>,
}

/// 规则做了决定、这个请求却一家上游都不会去时的路由事件:尝试链是空的。
/// 认出了密钥、这个请求却一家上游都不会去时的路由事件:尝试链是空的。
///
/// **这样的请求照样留一行**(开始、这一条、然后一条失败):被规则拒绝的、规则
/// 选中的上游都服务不了它的,和别的失败一样要在流量里看得见,每条规则命中了
/// 多少也要数得到它们。
/// **这样的请求照样留一行**(开始、这一条、然后一条失败):被规则拒绝的、模型准入没过的
/// (模型名写错了、没有上游提供、密钥不让用)、规则选中的上游都服务不了它的、规则没做出
/// 决定的、还没配上游的,和别的失败一样要在流量里看得见,每条规则命中了多少也要数得到
/// 它们。规则没做出决定的,`choice` 里决定去向的规则是空的。
pub(crate) fn routed_nowhere(id: u64, choice: Choice) -> tw_api::Event {
tw_api::Event::RequestRouted {
id,
Expand Down
77 changes: 47 additions & 30 deletions crates/tw-gateway/src/server/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,26 +103,16 @@ pub(super) async fn pipeline(
Probe::Intent(intent) => intent,
};

// 首次运行还没配完是正常状态,不是配置错误。这条要在路由之前挡,
// 因为「一个 provider 都没有」时任何路由结果都是空的,而那条错误
// 说不清下一步。
if rt.config.providers.is_empty() {
return Err(GatewayError::config(msg!(
"gw.config.no_upstreams" =>
"No upstream is configured yet. Add one in ThinkWatch Lite, or under `providers` in \
config.yaml."
)));
}

// 管线第 2 步:读出路由事实,路由。**看的是客户端的原话**:插件的请求钩子排在路由
// 之后(每发往一个上游跑一次,见 `plug`),左右不了请求去哪一家
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())? {
let (choice, decision) = match route(&state, &rt, &req, &reading, conv.as_ref()) {
Routed::Go(choice, decision) => (choice, decision),
// 规则做了决定,请求却一家上游都不会去。**照样开始、照样报路由**,失败由
// 请求一家上游都不会去:还没配上游、规则没做出决定、规则拒绝了、模型准入没过、规则
// 选中的上游都服务不了。**照样开始、照样报路由**(尝试链是空的),失败由
// `passthrough` 按这个错误报 —— 不排队:它一个字节都不会发出去
Routed::Refused(choice, why) => {
let to = ("", tw_api::Billing::PerToken);
Expand Down Expand Up @@ -604,6 +594,9 @@ fn outgrown(
/// 目录空着时不看清单:那说明探测还没回来或者上游都不给列表,这时候拦等于把整个网关
/// 关掉。**密钥的 `allow` 照样看**:它写在配置里,和清单问没问到无关 —— 不看的话,
/// 下一步按密钥跳过候选时,请求会以「没有上游可用」被拒,说不清是密钥的事。
///
/// 没过的请求**照样留一行**([`Routed::Refused`]):模型名写错了、密钥不让用,要在流量里
/// 看得见才排查得了。
fn admit(
state: &AppState,
rt: &Runtime,
Expand Down Expand Up @@ -752,29 +745,47 @@ fn admit(
enum Routed {
/// 选出了候选,照常往下走
Go(Choice, tw_engine::Decision),
/// 规则做了决定,请求却一家上游都不会去:规则拒绝了它,或者规则选中的上游
/// 都服务不了它。**这一行要留下**(见 [`super::routed_nowhere`])
/// 请求一家上游都不会去:还没配上游,规则没做出决定(没有一条命中、求不了值),规则
/// 拒绝了它,模型准入没过([`admit`]),或者规则选中的上游都服务不了它。**这一行要
/// 留下**(见 [`super::routed_nowhere`]):密钥认出来了,被拒的请求和别的失败一样要在
/// 流量里看得见。规则还没做出决定的,`Choice` 里决定去向的规则是空的
Refused(Choice, GatewayError),
}

/// 管线第 2 步的后半:路由。**规则引擎在这里** —— M0 那句「取第一个
/// provider」就是留给这一段的接缝。
///
/// 出来的候选已经去掉了服务不了的、按策略组排好了序;熔断过滤在并发
/// 闸门之后(见 [`start`])。
///
/// 返回的错误是**规则还没做出决定**的那些(没有一条规则命中、规则求不了值):
/// 那时说不出这个请求算在哪条规则上,不留这一行,和鉴权没过一样。模型准入没过
/// ([`admit`])也从这里返回,同样不留这一行 —— 和准入放在路由之前时一样。
/// 闸门之后(见 [`start`])。去不了任何一家的一律是 [`Routed::Refused`],留一行。
fn route(
state: &AppState,
rt: &Runtime,
req: &Inbound,
reading: &crate::client_api::Reading,
conv: Option<&crate::affinity::Conversation>,
) -> Result<Routed, GatewayError> {
) -> Routed {
let facts = &reading.facts;
let route = rt.engine.route_of(&req.client_name).to_string();
// 规则还没做出决定:说不出这个请求算在哪条规则上,决定去向的规则空着
let undecided = |why| {
Routed::Refused(
Choice {
route: route.clone(),
..Default::default()
},
why,
)
};
// 首次运行还没配完是正常状态,不是配置错误。这条要在规则求值之前挡,
// 因为「一个 provider 都没有」时任何路由结果都是空的,而那条错误
// 说不清下一步。
if rt.config.providers.is_empty() {
return undecided(GatewayError::config(msg!(
"gw.config.no_upstreams" =>
"No upstream is configured yet. Add one in ThinkWatch Lite, or under `providers` in \
config.yaml."
)));
}
let now = now_ms();
// 同一轮里沿用这一轮开头的决定:按输入大小、有没有图片分流的规则,不能让一轮
// 半路换家(见 `crate::affinity`)。输入超出了所选模型的上下文的,重新求值
Expand All @@ -784,16 +795,19 @@ fn route(
let held_route = held.is_some();
let mut decision = match held {
Some(d) => d,
None => match rt.engine.route(facts).map_err(|e| {
GatewayError::config(msg!("gw.route.failed", detail = e => "Routing failed: {detail}"))
})? {
tw_engine::Outcome::Route(d) => {
None => match rt.engine.route(facts) {
Err(e) => {
return undecided(GatewayError::config(msg!(
"gw.route.failed", detail = e => "Routing failed: {detail}"
)));
}
Ok(tw_engine::Outcome::Route(d)) => {
if let Some(c) = conv {
state.affinity.decided(c, &rt.engine, &d, now);
}
d
}
tw_engine::Outcome::Deny { rule, reason } => {
Ok(tw_engine::Outcome::Deny { rule, reason }) => {
// **带理由的拒绝。**一个没有理由的拒绝,和一个 bug,在用户
// 眼里没有区别。
tracing::info!(%rule, "a rule denied the request");
Expand All @@ -810,7 +824,7 @@ fn route(
affinity: None,
stayed_on: None,
};
return Ok(Routed::Refused(choice, why));
return Routed::Refused(choice, why);
}
},
};
Expand All @@ -832,7 +846,10 @@ fn route(
facts,
&decision,
);
admit(state, rt, req, reading, &decision, &asked)?;
// 准入没过:规则做了决定,模型却去不了(写错了、没有上游提供、密钥不让用)。照样留一行
if let Err(why) = admit(state, rt, req, reading, &decision, &asked) {
return Routed::Refused(choice, why);
}
// 每一家发出去的名字:别名对到各家自己的名称。跳过、比价看它(见 `crate::sent`)
let catalog = state.catalog.load();
let allow = crate::models::key_allow(&rt.config, &req.client_name);
Expand All @@ -847,7 +864,7 @@ fn route(
let serving = crate::sent::serving(&rt.config, &catalog, &decision, &asked, allow);
let model = decision.set.model.as_deref().unwrap_or(&facts.model);
if serving.usable.is_empty() {
return Ok(Routed::Refused(choice, serving.explain(model)));
return Routed::Refused(choice, serving.explain(model));
}
if !serving.skipped.is_empty() {
tracing::debug!(skipped = ?serving.skipped, %model, "skipping the candidates that cannot serve this request");
Expand All @@ -874,7 +891,7 @@ fn route(
if let Some(leader) = decision.candidates.first() {
arranged.charge(&decision.candidates, leader);
}
Ok(Routed::Go(choice, decision))
Routed::Go(choice, decision)
}

/// 排好了的候选([`arrange`]):会话粘性留下了哪一家的理由,和 `load-balance` 这一次还没记的账。
Expand Down
Loading
Loading