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
69 changes: 63 additions & 6 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ slug_enum! {
/// 换模型,整个 prompt cache 作废
Model = "model",
MaxTokens = "max_tokens",
Thinking = "thinking",
/// 推理:值是 [`RuleReasoning`] 的那个词
Reasoning = "reasoning",
/// 以上改写只在新会话开始时应用
OnlyAtSessionStart = "only_at_session_start",
}
Expand Down Expand Up @@ -1074,7 +1075,16 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// [`AttemptView`] 多了 `retry`(这一跳是第几次重试)和 `backoff_ms`(发出之前退避了多久)。
/// 新码:`gw.upstream.stream_ended_early`(流只发了开头的例行事件就收了尾,算在
/// [`FAILED_AFTER_SENDING`] 里)
pub const CONTROL_API_VERSION: u32 = 53;
///
/// **54 起路由规则改的是推理,不再是扩展思考**:[`RuleRewrite`] 的 `thinking`(布尔)换成
/// `reasoning`([`RuleReasoning`]:`off` 关掉推理,或者改成 `low` / `medium` / `high` / `xhigh` /
/// `max` 一档,只改开着推理的请求),[`SetField`] 的 `thinking` 换成 `reasoning`([`SetView`] 的
/// 值是那个词)。配置里的 `set.thinking` 不认了。[`AttemptView`] 多了 `effort`([`EffortChange`]:
/// 这一跳发出去的推理强度和客户端要的不一样,`reason` 是 [`EffortReason`] 的 `rule` / `rejected` /
/// `remembered`):上游因为推理强度回 400 时,同一家改一档(或者去掉强度)再发一次,是尝试链上
/// 新的一跳,不算退避重试;记下这一家的这个模型不认那一档,7 天之内直接改。照 53 写的界面
/// 保存规则时带的 `thinking` 不起作用
pub const CONTROL_API_VERSION: u32 = 54;

/// `/live` 的速度看最近多久(见 [`LiveView::tokens_per_sec`]),毫秒
pub const LIVE_RATE_WINDOW_MS: u64 = 3_000;
Expand Down Expand Up @@ -2008,13 +2018,45 @@ pub struct AttemptView {
/// 这一跳发出去之前退避了多少毫秒:一轮重试的头一跳带着。不算在 `ms` 里。别的没有
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backoff_ms: Option<u64>,
/// 这一跳改了推理强度:规则改写的、上游拒收了改用别的一档重发的、记得这个模型不认那一档直接
/// 改了的(见 [`EffortChange`])。改了也和原来一样的没有。被拒收的那一跳自己是一跳普通的
/// `status`(400),重发的那一跳带着 `rejected`
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortChange>,
/// 这一跳走的代理:这家上游配置的代理名(跟随系统的是 `system`),直连是 None。没发出去
/// 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在
/// 这个代理上。WebSocket 只连直连的上游,一律是 None
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
}

/// 一跳改了推理强度([`AttemptView::effort`])。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct EffortChange {
/// 这一次改之前要发的那一档:`rule` 是客户端要的;`rejected`、`remembered` 是被拒收、被换掉的
/// 那一档(规则改过之后的)—— 客户端要 high、规则改成 xhigh、被拒后改用 max,这一项是
/// `xhigh`。词表:`none`(OpenAI 的「关掉推理」)/ `minimal` / `low` / `medium` / `high` /
/// `xhigh` / `max`
pub asked: String,
/// 发出去的那一档,同一个词表(规则 `off` 在 OpenAI 的格式里是 `none`)。null 是去掉了推理
/// 强度,按模型的默认
pub sent: Option<String>,
pub reason: EffortReason,
}

slug_enum! {
/// 推理强度为什么改了([`EffortChange::reason`])。
pub enum EffortReason {
/// 路由规则改写(`set.reasoning`)
Rule = "rule",
/// 上游拒收了上一跳的那一档(400),同一家改用这一档重发了一次
Rejected = "rejected",
/// 这一家的这个模型拒收过那一档,记着(7 天),直接改了
Remembered = "remembered",
}
}

/// 发出去之后才失败的一跳报的码(见 [`AttemptView::sent`]):上游没在时限内回话、请求在路上
/// 断了、回答读到一半连接断了、流在第一段内容之前报了错。**上游可能已经收下了它、在算了**。
///
Expand Down Expand Up @@ -2807,9 +2849,22 @@ pub struct RuleRewrite {
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
/// 打开或关闭扩展思考
/// 推理:关掉,或者改成一档。**只改开着推理的请求**,没开推理的(生成标题这类)原样不动
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<bool>,
pub reasoning: Option<RuleReasoning>,
}

slug_enum! {
/// 规则改写的推理([`RuleRewrite::reasoning`])。没有 `minimal`
pub enum RuleReasoning {
/// 关掉推理,连同推理强度
Off = "off",
Low = "low",
Medium = "medium",
High = "high",
XHigh = "xhigh",
Max = "max",
}
}

/// 规则里的一个条件。
Expand Down Expand Up @@ -5949,8 +6004,9 @@ pub struct MismatchView {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SetView {
/// `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `thinking` /
/// `only_at_session_start`(以上改写只在新会话开始时应用,值是 `true`)
/// `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `reasoning`(值是
/// [`RuleReasoning`] 的那个词)/ `only_at_session_start`(以上改写只在新会话开始时应用,
/// 值是 `true`)
pub field: SetField,
pub value: String,
}
Expand Down Expand Up @@ -6929,6 +6985,7 @@ mod tests {
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}
}
Expand Down
16 changes: 12 additions & 4 deletions crates/tw-config/tests/manual/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use super::{Def, Kind, Lang, Row, Section, T2, Ty};
use tw_config::proxy::ProxyAuth;
use tw_config::*;
use tw_engine::rule::When;
use tw_engine::{BalanceBy, Group, GroupType, Member, Pinned, RouteSet, Rule, SetAction};
use tw_engine::{
BalanceBy, Group, GroupType, Member, Pinned, RouteSet, Rule, SetAction, SetReasoning,
};
use tw_pricing::{PerMillion, PricingConfig, SheetDef};

const fn t(en: &'static str, zh: &'static str) -> T2 {
Expand Down Expand Up @@ -86,6 +88,9 @@ fn balance_bys() -> Vec<&'static str> {
fn limit_pers() -> Vec<&'static str> {
super::fields::<LimitPer>()
}
fn set_reasonings() -> Vec<&'static str> {
super::fields::<SetReasoning>()
}

/// 同步位置三种写法各自的字段。`target` 是按 `type` 分的枚举,问 serde 要字段的办法问不出
/// 枚举每一种的字段,所以借它的报错:写一个不存在的字段,serde 说出这一种认得的全部字段
Expand Down Expand Up @@ -1897,10 +1902,13 @@ pub fn sections() -> Vec<Section> {
t("Replace `max_tokens`.", "替换 `max_tokens`。"),
),
row(
"thinking",
Kind::Bool,
"reasoning",
Kind::Enum(set_reasonings),
Def::Unset,
t("Turn extended thinking on or off.", "开启或关闭扩展思考。"),
t(
"Reasoning. `off` turns it off: the reasoning settings and the effort are removed, and in the OpenAI formats the effort is set to `none`. A level changes the effort. Only requests that already have reasoning on are changed; requests without reasoning (such as title generation) are left as they are.",
"推理。`off` 关闭推理:去掉推理设置和推理强度,OpenAI 的格式改为写 `none`。写一档则改为该档强度。只改写已开启推理的请求,未开启推理的请求(如生成标题)保持不变。",
),
),
row(
"only_at_session_start",
Expand Down
4 changes: 2 additions & 2 deletions crates/tw-control/src/dryrun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,8 @@ fn describe(set: &tw_engine::SetAction) -> Vec<tw_api::SetView> {
if let Some(t) = set.max_tokens {
push(SetField::MaxTokens, t.to_string());
}
if let Some(t) = set.thinking {
push(SetField::Thinking, t.to_string());
if let Some(r) = set.reasoning {
push(SetField::Reasoning, r.slug().to_string());
}
if set.only_at_session_start {
push(SetField::OnlyAtSessionStart, "true".to_string());
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-control/src/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fn renamed(row: &tw_store::db::RequestRow, raw: Vec<u8>, model: &str) -> (bytes:
..Default::default()
};
(
tw_gateway::forward::apply_set(&raw, &set, Some(api.dialect())),
tw_gateway::forward::apply_set(&raw, &set, Some(api.dialect()), model),
row.path.clone(),
)
}
Expand Down
24 changes: 22 additions & 2 deletions crates/tw-control/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ pub(crate) fn to_rule(input: &tw_api::RuleInput, cfg: &tw_config::Config) -> Res
.filter(|m| !m.is_empty())
.map(str::to_string),
max_tokens: s.max_tokens,
thinking: s.thinking,
reasoning: s.reasoning.map(set_reasoning),
only_at_session_start: false,
};
(!a.is_empty()).then_some(a)
Expand Down Expand Up @@ -811,14 +811,34 @@ pub(crate) fn rule_view(r: &Rule, n: tw_engine::RuleNotes) -> tw_api::RuleView {
.map(|s| tw_api::RuleRewrite {
model: s.model.clone(),
max_tokens: s.max_tokens,
thinking: s.thinking,
reasoning: s.reasoning.map(rule_reasoning),
}),
catch_all: n.catch_all,
phase_two: n.phase_two,
shadowed: n.shadowed,
}
}

/// 规则改写的推理:界面的词 → 配置里的
fn set_reasoning(r: tw_api::RuleReasoning) -> tw_engine::SetReasoning {
use tw_api::RuleReasoning as R;
use tw_dialect::ir::Effort;
use tw_engine::SetReasoning as S;
match r {
R::Off => S::Off,
R::Low => S::Level(Effort::Low),
R::Medium => S::Level(Effort::Medium),
R::High => S::Level(Effort::High),
R::XHigh => S::Level(Effort::XHigh),
R::Max => S::Level(Effort::Max),
}
}

/// 反过来。两边的词是同一张表([`tw_engine::SetReasoning::slug`])
fn rule_reasoning(r: tw_engine::SetReasoning) -> tw_api::RuleReasoning {
tw_api::RuleReasoning::from_slug(r.slug()).unwrap_or(tw_api::RuleReasoning::Low)
}

// ─────────────────────────────────────────────────────────── 共用

fn reserved(name: &str, what: &str) -> Result<(), Msg> {
Expand Down
21 changes: 21 additions & 0 deletions crates/tw-control/tests/dryrun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,27 @@ async fn a_set_only_rule_still_shows_up_as_matched_and_says_what_it_changes() {
);
}

/// 推理改写在试算里是一项 `reasoning`,值是配置里的那个词
#[tokio::test]
async fn a_reasoning_rewrite_shows_as_its_word() {
let (_d, app) = app_with(&CFG.replace(
" set: { model: claude-haiku-4-5 }",
" set: { model: claude-haiku-4-5, reasoning: xhigh }",
));
let r = run(
&app,
r#"{"model":"claude-sonnet-4-5","input_tokens":300000}"#,
)
.await;
assert!(
r.set
.iter()
.any(|s| s.field == tw_api::SetField::Reasoning && s.value == "xhigh"),
"{:?}",
r.set
);
}

#[tokio::test]
async fn the_dry_run_changes_nothing_and_sends_nothing() {
// 它只算。跑一百遍和跑零遍,对上游和配置文件来说没有区别。
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-control/tests/handover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async fn status_reports_the_handover_phase_and_the_draining_count() {
let st = status(&s).await;
assert_eq!(st.handover, tw_api::HandoverState::Draining);
assert_eq!(st.draining, 3);
assert_eq!(st.api_version, 53);
assert_eq!(st.api_version, 54);
}

/// arm 之后(以及接替者正在接管时)不开始新的账号登录:回调端口不交接。(平常、排空中的
Expand Down
1 change: 1 addition & 0 deletions crates/tw-control/tests/in_flight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ async fn it_gives_every_running_request_with_what_has_happened_to_it_so_far() {
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}],
billing: tw_api::Billing::PerToken,
Expand Down
2 changes: 2 additions & 0 deletions crates/tw-control/tests/key_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ fn request(id: u64, key: &str, at: i64, input: u64) -> Vec<Event> {
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}],
billing: tw_api::Billing::PerToken,
Expand Down Expand Up @@ -460,6 +461,7 @@ fn hop(outcome: tw_api::AttemptOutcome, code: &str, busy: bool) -> tw_api::Attem
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/tw-control/tests/live_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async fn a_request_still_running_can_be_opened_and_becomes_whole_when_it_ends()
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}],
billing: tw_api::Billing::PerToken,
Expand Down
1 change: 1 addition & 0 deletions crates/tw-control/tests/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ fn routed(hops: &[(&str, Option<&str>)]) -> Option<String> {
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
})
.collect();
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-control/tests/route_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ routes:
deny: 这个项目不用 opus
- name: 关掉思考
when: {{ model: claude-sonnet-* }}
set: {{ thinking: false }}
set: {{ reasoning: off }}
- name: 从没命中过
when: {{ model: gpt-* }}
to: up
Expand Down
44 changes: 44 additions & 0 deletions crates/tw-control/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,50 @@ async fn a_rule_that_only_rewrites_can_be_saved() {
assert!(codex["rules"][0].get("guard").is_none(), "{codex}");
}

/// 推理改写是一个词:存进配置写成 `reasoning: xhigh`,读回来还是那个词;不认识的词
/// (`minimal` 也是)读不成请求体
#[tokio::test]
async fn a_reasoning_rewrite_is_saved_as_its_word() {
let b = bed(BASE);
for word in ["off", "xhigh"] {
let (st, v) = call(
&b.app,
"PUT",
"/routes/codex",
json!({ "route": { "name": "codex", "rules": [
{ "name": "推理", "conditions": [], "set": { "reasoning": word } },
catch_all("主力"),
]}}),
)
.await;
assert_eq!(st, StatusCode::OK, "{v}");
assert!(
b.file().contains(&format!("reasoning: {word}")),
"{}",
b.file()
);
let codex = find(&b.overview().await["routes"], "codex").clone();
assert_eq!(
codex["rules"][0]["set"],
json!({ "reasoning": word }),
"{codex}"
);
}
let before = b.file();
let (st, v) = call(
&b.app,
"PUT",
"/routes/codex",
json!({ "route": { "name": "codex", "rules": [
{ "name": "推理", "conditions": [], "set": { "reasoning": "minimal" } },
catch_all("主力"),
]}}),
)
.await;
assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "{v}");
assert_eq!(b.file(), before);
}

#[tokio::test]
async fn deleting_a_route_sends_its_keys_where_the_user_chose() {
let b = bed(BASE);
Expand Down
1 change: 1 addition & 0 deletions crates/tw-control/tests/upstream_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ fn attempt(provider: &str, status: u16) -> tw_api::AttemptView {
pause_ms: None,
retry: None,
backoff_ms: None,
effort: None,
proxy: None,
}
}
Expand Down
Loading
Loading