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.

3 changes: 3 additions & 0 deletions crates/tw-api/src/ep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ endpoints! {
/// 一次会话读成一段对话:每一轮新说的话、回答、工具调用和结果(已脱敏)。可以只要
/// 从某一轮起的那些
SessionTranscript: GET "/sessions/{id}/transcript" [id], api::TranscriptQuery => api::Transcript;
/// 一次会话里的一轮,上下文由什么占着:系统提示、工具、历史、最后一条用户消息各估多少
/// token,连同实测的输入和缓存读。会话不存在、那一轮不在这次会话里都是 404
SessionTurnContext: GET "/sessions/{id}/turns/{turn}/context" [id, turn], () => api::TurnContext;
/// 中止一次会话里所有在跑的请求。一个都没有是 404
AbortSession: POST "/sessions/{id}/abort" [id], () => api::Aborted;

Expand Down
53 changes: 52 additions & 1 deletion crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,16 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// 客户端收到的话、删掉了几段,[`OutcomeDetail`])。[`RequestDetail`] 的 `security` 是同一个
/// 类型。事件跟着改:[`SecretItem`]、[`Event::ContentMatched`]、[`Event::ToolCallFlagged`]
/// 多了 `detail`([`SecurityHitDetail`])。细节存在请求记录的库里,和请求一起过期。
pub const CONTROL_API_VERSION: u32 = 44;
///
/// **45 起会话里的每一轮说得出上下文窗口有多大、被什么占着**:[`TurnView`] 多了
/// `context_window`(答这一轮的那家那个模型的上下文窗口,按**此刻**知道的规格:手写的
/// 优先,其次价目表;不知道是 null)。新端点 `GET /sessions/{id}/turns/{turn}/context`
/// (→ [`TurnContext`]):这一轮存下来的请求体按系统提示、工具定义、历史、最后一条用户消息
/// 四块各估多少 token(和数 token 由网关自己答的是同一个估算),连同实测的输入和缓存读。
/// 请求体没存下来、已经清掉、只存了开头或读不懂的,`kept` 是 false、`parts` 是 null。
/// 会话不存在是 404(`control.session_not_found`),那一轮不在这次会话里也是 404
/// (`control.request_not_found`)。照 44 写的界面读不到 `context_window`。
pub const CONTROL_API_VERSION: u32 = 45;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
Expand Down Expand Up @@ -5019,6 +5028,10 @@ pub struct TurnView {
/// 服务它的那家怎么收钱,和 `HistoryRow::billing` 同一套词:`per-token` /
/// `free`
pub billing: Billing,
/// 答这一轮的那家那个模型的上下文窗口(token),按**此刻**知道的规格:这一家手写的
/// `model_specs` 优先,其次价目表。不知道是 None。**不是那一轮当时的数** —— 规格改了
/// 这里跟着变
pub context_window: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -5028,6 +5041,44 @@ pub struct SessionDetail {
pub turns: Vec<TurnView>,
}

/// 一轮的上下文由什么占着(`GET /sessions/{id}/turns/{turn}/context`)。
///
/// `parts` 是**本地的估算**,在存下来的请求体上算,和数 token 由网关自己答的那个数是
/// 同一个算法(`tw_engine::estimate_tokens`);`input_tokens`、`cache_read_tokens` 是上游
/// 实测报的。两个数差一两成是正常的,界面别把估算画成实测。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct TurnContext {
/// 请求体还在不在。没存下来、已经清掉(`retention.body_days`)、只存了开头、读不懂
/// 的都是 false,那时 `parts` 是 null
pub kept: bool,
/// 和 [`TurnView::context_window`] 同一个数
pub window: Option<u64>,
/// 这一轮实测的输入 token(上游按它收费),记录里的那个
pub input_tokens: Option<i64>,
/// 这一轮实测的缓存读 token,记录里的那个
pub cache_read_tokens: Option<i64>,
/// 四块各估多少。请求体不在了是 null
pub parts: Option<ContextParts>,
}

/// 请求体的四块各估多少 token。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct ContextParts {
/// 系统提示:Anthropic 的 `system`、Chat 和 Responses 的 system、developer 消息、
/// Responses 的 `instructions`、Gemini 的 `systemInstruction`
pub system: u64,
/// 工具定义
pub tools: u64,
/// 最后一条用户消息之前的每一条消息
pub history: u64,
/// 最后一条用户消息,连同它带着的工具结果
pub last_user: u64,
/// 四块之和
pub total: u64,
}

/// 一次会话读成一段对话(`GET /sessions/{id}/transcript`):每一轮新说的话、回答、推理、
/// 工具调用和工具结果。
///
Expand Down
31 changes: 31 additions & 0 deletions crates/tw-api/src/ts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,37 @@ mod tests {
assert!(event.contains("answered_model?: string"), "{event}");
}

/// 一轮的上下文:两个路径参数都在,`parts` 可以是 null,四块和合计都是数
#[test]
fn a_turn_context_has_its_parts_or_null() {
let ts = typescript();
assert!(ts.contains(" SessionTurnContext: { req: null; res: TurnContext };"));
assert!(ts.contains(
" SessionTurnContext: { method: \"GET\", path: \"/sessions/{id}/turns/{turn}/context\", params: [\"id\", \"turn\"], format: \"json\" },"
));
let context = decl_of(&ts, "TurnContext");
for field in [
"kept: boolean",
"window: number | null",
"input_tokens: number | null",
"cache_read_tokens: number | null",
"parts: ContextParts | null",
] {
assert!(context.contains(field), "{field} in {context}");
}
let parts = decl_of(&ts, "ContextParts");
for field in [
"system: number",
"tools: number",
"history: number",
"last_user: number",
"total: number",
] {
assert!(parts.contains(field), "{field} in {parts}");
}
assert!(decl_of(&ts, "TurnView").contains("context_window: number | null"));
}

/// 对话记录:请求号是字符串;可以为空的是 null(必有的字段,不是省掉);块是按 `kind`
/// 分的联合,角色和缺口是字面量
#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/tw-control/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ tw-config = { workspace = true }
tw-guard = { workspace = true }
bytes = { workspace = true }
tw-engine = { workspace = true }
# 一轮的上下文按块估:存下来的请求体按客户端的格式解码
tw-dialect = { workspace = true }
tw-gateway = { workspace = true }
# 重放发给 Bedrock 的请求要签名、拆帧
tw-bedrock = { workspace = true }
Expand Down
100 changes: 98 additions & 2 deletions crates/tw-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ pub fn router(state: ControlState) -> Router {
.at(ep::Sessions, sessions)
.at(ep::SessionDetail, session_detail)
.at(ep::SessionTranscript, session_transcript)
.at(ep::SessionTurnContext, session_turn_context)
.at(ep::AbortSession, abort_session)
.at(ep::DryRun, dryrun::dry_run)
// 为客户端发专用密钥。接管本身在桌面端做
Expand Down Expand Up @@ -1735,8 +1736,24 @@ fn session_view(s: &tw_store::db::SessionRow) -> tw_api::SessionView {
}
}

fn turn_view(t: &tw_store::db::TurnRow) -> tw_api::TurnView {
/// 答这一轮的那家那个模型,**此刻**的上下文窗口:和 `/v1/models`、上游页、别名同一个查法
/// (`tw_config::model_specs`),按发给上游的名字查 —— 上游收到的是它
fn context_window(
cfg: &tw_config::Config,
book: &tw_pricing::PriceBook,
provider: &str,
sent_model: &str,
) -> Option<u64> {
cfg.model_spec(book, provider, sent_model).context_window()
}

fn turn_view(
cfg: &tw_config::Config,
book: &tw_pricing::PriceBook,
t: &tw_store::db::TurnRow,
) -> tw_api::TurnView {
tw_api::TurnView {
context_window: context_window(cfg, book, &t.provider, &t.sent_model),
id: t.id,
at_ms: t.at_ms as u64,
model: t.model.clone(),
Expand Down Expand Up @@ -1794,12 +1811,91 @@ async fn session_detail(
.map_err(records)?
};
let session = session.ok_or_else(|| no_such_session(&id))?;
let (cfg, book) = (s.config(), s.gateway.pricing.load());
Ok(Json(tw_api::SessionDetail {
session: session_view(&session),
turns: turns.iter().map(turn_view).collect(),
turns: turns.iter().map(|t| turn_view(&cfg, &book, t)).collect(),
}))
}

/// 一轮的上下文由什么占着(见 [`tw_api::TurnContext`])。
///
/// 请求体按客户端的格式解码成中间表示,再按块估 token(`tw_engine::estimate_parts`):
/// 四种格式的系统提示、工具、消息都读到同一个地方,和数 token 由网关自己答的是同一个
/// 估算。**正文读和解析在阻塞线程上**,锁只在取行的时候拿:一份请求体可以有几 MB。
async fn session_turn_context(
State(s): State<ControlState>,
axum::extract::Path((id, turn)): axum::extract::Path<(String, i64)>,
) -> Result<Json<tw_api::TurnContext>, Fail> {
let store = need_store(&s)?;
let (row, blobs) = {
let id = id.clone();
on_store(store, move |g| {
let row = g
.db()
.get(turn)?
.filter(|r| !r.local && r.session.as_deref() == Some(id.as_str()));
let Some(row) = row else {
// 会话不存在和那一轮不在这次会话里是两个 404
return Ok(Err(match g.db().session(&id)? {
Some(_) => fail(
StatusCode::NOT_FOUND,
msg!("control.request_not_found", id = turn => "There is no request {id}."),
),
None => no_such_session(&id),
}));
};
// 正文目录只是一个路径,读它不需要锁
let blobs = tw_store::Blobs::new(g.blobs().root().to_path_buf());
Ok::<_, tw_store::DbError>(Ok((row, blobs)))
})
.await?
.map_err(records)??
};
let window = {
let (cfg, book) = (s.config(), s.gateway.pricing.load());
context_window(&cfg, &book, &row.provider, &row.sent_model)
};
let (input_tokens, cache_read_tokens) = (row.input_tokens, row.cache_read_tokens);
let parts = tokio::task::spawn_blocking(move || stored_parts(&row, &blobs))
.await
.map_err(internal)?;
Ok(Json(tw_api::TurnContext {
kept: parts.is_some(),
window,
input_tokens,
cache_read_tokens,
parts,
}))
}

/// 存下来的请求体按块估。没存、清掉了、只存了开头、解析不了、解码不了的都是 None。
fn stored_parts(
row: &tw_store::db::RequestRow,
blobs: &tw_store::Blobs,
) -> Option<tw_api::ContextParts> {
let raw = blobs.get(row.at_ms, row.id, tw_store::Which::Request)?;
if blobs
.original_len(row.at_ms, row.id, tw_store::Which::Request)
.is_some_and(|n| n > raw.len())
{
return None;
}
let v: serde_json::Value = serde_json::from_slice(&raw).ok()?;
let client = tw_store::search::text::client_dialect(&row.path)?;
let r = tw_dialect::convert::decode(client, &v, &row.path, None)
.ok()?
.request;
let p = tw_engine::estimate_parts(&r);
Some(tw_api::ContextParts {
system: p.system,
tools: p.tools,
history: p.history,
last_user: p.last_user,
total: p.total(),
})
}

/// 中止一个在跑的请求(见 `tw_gateway::abort`)。**叫停是立刻的**:和上游的连接在请求自己
/// 那边断开,结局(`RequestFailed`,来源 `aborted`)随后从事件流上到。已经结束了的、从来
/// 没有过的、跑在 WebSocket 连接上的,都是不在跑:404
Expand Down
Loading
Loading