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
3 changes: 2 additions & 1 deletion scripts/shots/mock/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ const COMMANDS: Table = {

dashboard: (a) => {
const { sinceMs, bucketMs } = need("dashboard", a, "sinceMs", "bucketMs");
return dashboard(sinceMs, bucketMs);
// 订阅额度是此刻的状态,和上游页同一份(dashboard.rs 同样问 `/quota`)
return { ...dashboard(sinceMs, bucketMs), quotas: [{ provider: "chatgpt", windows: CHATGPT_WINDOWS() }] };
},
upstream_stats: (a) => {
const { sinceMs, bucketMs, egressSinceMs } = need("upstream_stats", a, "sinceMs", "bucketMs", "egressSinceMs");
Expand Down
3 changes: 3 additions & 0 deletions scripts/shots/mock/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
unpricedModels,
upstreamHealth,
} from "./traffic";
import { usageHistory } from "./history";
import { DAY, HOUR, NOW, clone, msg } from "./util";

type Handler<N extends WebviewEndpoint> = (req: Endpoints[N]["req"], params: string[]) => Endpoints[N]["res"];
Expand Down Expand Up @@ -185,6 +186,8 @@ export const CORE: { [N in WebviewEndpoint]: Handler<N> } = {
// 没给时间窗就是最近 7 天(`UpstreamHealth` 的默认)
UpstreamHealth: (req) => upstreamHealth(req.from_ms ?? NOW - 7 * DAY, req.to_ms ?? Date.now()),
RouteStats: (req) => routeStats(req.from_ms ?? new Date(NOW).setHours(0, 0, 0, 0), req.to_ms ?? Date.now()),
// 概览「最近一年」:一年左右的逐日用量,记录从十个月前开始
UsageHistory: (req) => usageHistory(req),

Pricing: () => pricing(unpricedModels()),
RefreshPricing: refuse,
Expand Down
65 changes: 65 additions & 0 deletions scripts/shots/mock/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 用量历史(`GET /summary/history`):概览「最近一年」那张热力图。
//
// 一年左右的逐日用量:工作日多、周末少、偶尔几天没用,越近用得越多;费用有一小部分是估算的,
// 偶尔几条无法计价。记录从十个月前开始。格子按 core 的分法排:`from + k × bucket`,稀疏。
import type { BucketQuery, CostBucket, UsageHistory } from "@/types";
import { DAY, NOW, rng } from "./util";

/** 记录从哪一刻起:十个月前 */
const SINCE = NOW - 304 * DAY;
/** 逐条记录(请求库)只有最近 30 天:更早的日子在热力图上点不开 */
const DETAIL_SINCE = () => NOW - 30 * DAY;

export function usageHistory(req: BucketQuery, sinceMs: number = SINCE): UsageHistory {
const now = Date.now();
const bucket = req.bucket_ms ?? DAY;
const from = req.from_ms ?? now - 365 * DAY;
const to = Math.min(req.to_ms ?? now, now);
const r = rng(20261028);
const buckets: CostBucket[] = [];
for (let k = 0; from + k * bucket < to; k++) {
const at = from + k * bucket;
const rolls = [r(), r(), r(), r()] as const;
// 记录之前没有数据;格子按 `at + 12h` 那一刻对本地日期,这里同样按它判断工作日
if (at + bucket <= sinceMs) continue;
const mid = new Date(at + bucket / 2);
const weekend = mid.getDay() === 0 || mid.getDay() === 6;
if (rolls[0] < (weekend ? 0.35 : 0.05)) continue;
const age = (now - at) / DAY;
const grow = 0.35 + 0.65 * Math.pow(Math.max(0, 1 - age / 365), 1.5);
const spike = rolls[3] < 0.04 ? 2.4 : 1;
// 今天只过了一部分
const part = Math.min(1, (to - at) / bucket);
const tokens = Math.round(2.6e6 * grow * (weekend ? 0.35 : 1) * spike * (0.45 + rolls[1] * 1.1) * part);
if (tokens <= 0) continue;
const output = Math.round(tokens * (0.05 + rolls[1] * 0.04));
const cacheRead = Math.round(tokens * (0.52 + rolls[0] * 0.14));
const cacheWrite = Math.round(tokens * 0.04);
const input = tokens - output - cacheRead - cacheWrite;
const requests = Math.max(1, Math.round(tokens / (21_000 + rolls[2] * 14_000)));
const cost = Math.round(tokens * (1.25 + rolls[2] * 0.5));
// 大约每七天有一天含估算(那一天费用的一成多),每二十天有一天几条无法计价
const estimated = rolls[2] < 0.15 ? Math.round(cost * 0.12) : 0;
const unpriced = rolls[3] > 0.95 ? 1 + Math.floor(rolls[1] * 3) : 0;
buckets.push({
at_ms: at,
requests,
failed: Math.floor(requests * 0.02 * rolls[0]),
cost_micros_exact: cost - estimated,
cost_micros_estimated: estimated,
unpriced_requests: unpriced,
no_usage_requests: 0,
sent_bytes: Math.round(tokens * 3.6),
received_bytes: Math.round(output * 5.6),
input_tokens: input,
output_tokens: output,
cache_read_tokens: cacheRead,
cache_write_tokens: cacheWrite,
// 用量历史不记首 token
ttft_p50_ms: null,
ttft_p95_ms: null,
ttft_samples: 0,
});
}
return { since_ms: sinceMs, detail_since_ms: DETAIL_SINCE(), buckets };
}
3 changes: 2 additions & 1 deletion scripts/shots/mock/traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,8 @@ function tokenRate(rows: HistoryRow[], key: (h: HistoryRow) => string): TokenRat
});
}

export function dashboard(sinceMs: number, bucketMs: number): Dashboard {
/** 订阅额度不在这里:`commands.ts` 和上游页那份一起给 */
export function dashboard(sinceMs: number, bucketMs: number): Omit<Dashboard, "quotas"> {
const rows = rowsBetween(sinceMs).filter((h) => !h.local);
const buckets = new Map<number, CostBucket>();
const byModel = new Map<string, CostBucketGroup>();
Expand Down
28 changes: 14 additions & 14 deletions src-tauri/Cargo.lock

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

12 changes: 6 additions & 6 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ license = "MIT"
# twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是
# 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。
[workspace.dependencies]
tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.2" }
tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }
tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }
tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }
tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }
tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.84.0" }

[lib]
name = "thinkwatch_lite_lib"
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ webview_endpoints![
AliasUsage,
// 各条路由、各条规则命中了多少:路由图按它给线加权、标出从没命中过的规则
RouteStats,
// 用量历史:概览上「最近一年」的热力图,一天一格
UsageHistory,
// 上游体检:各家上游的事实和样本数,界面按它写成自己的话
UpstreamHealth,
// 价目表
Expand Down
31 changes: 22 additions & 9 deletions src-tauri/src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,37 @@ pub async fn dashboard(
// **没有兜底。**这一层替界面挑一个时间窗(原来是「最近 24 小时」),就又有一处
// 不按本地时钟定的窗口;缺参数是界面的错,该在那边报出来。
since_ms: i64,
// 时间窗的终点(不含)。**只有「某一天」那种区间给**(界面的 `dayRange`):别的区间
// 都到现在为止。终点还在将来(点的是今天)时按到现在算
until_ms: Option<i64>,
bucket_ms: i64,
) -> Out<Dashboard> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let since = since_ms.min(now);
let until = until_ms.filter(|&u| u > since && u < now);
// 这一段有多长:上一个等长区间从它往前量
let end = until.unwrap_or(now);
let bucket = bucket_ms.max(60_000);
// **十四样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时
// 十四问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。
// **十五样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时
// 十五问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。
// 钥匙只读一次(`pin`)
let c = state.control.pin().map_err(text)?;
// 延迟、生成速度和汇总**同一个时间窗**。不给窗口的话 core 按「今天零点至今」算,
// 而界面上它们和上面的数字摆在一起,读的人会当成同一段时间
let win = window(Some(since), None);
let win = window(Some(since), until);
let buckets = tw_api::BucketQuery {
from_ms: Some(since),
to_ms: None,
to_ms: until,
bucket_ms: Some(bucket),
};
// 同样的格子按三个维度分:模型(趋势图的 token 合计、明细表的「模型」)、上游和
// 密钥(明细表的另两页)。**格宽和模型那份一样**,三份的格子对得上
let by = |dim| tw_api::BucketGroupQuery {
from_ms: Some(since),
to_ms: None,
to_ms: until,
bucket_ms: Some(bucket),
dim,
};
Expand All @@ -63,18 +69,18 @@ pub async fn dashboard(
);
// **上一个等长区间。**一个没有参照系的金额只能读,不能判断
// ——「$4.05」是多还是少,只有和上一个七天比过才知道
let before = window(Some(since - (now - since)), Some(since));
let before = window(Some(since - (end - since)), Some(since));
// **整段时间的首 token 分位**:问一个宽过整个时间窗的格子,整段落在第 0 格。分位数不能
// 从每一格(或每个模型)的分位合出来 —— 中位数不能这么合 —— 只能让 core 拿整段的样本
// 求。终点和汇总一样不给(到 core 的「现在」),两边数的是同一批请求
// 求。终点和汇总一样(某一天到那天末尾,别的到 core 的「现在」),两边数的是同一批请求
let whole = |from_ms, to_ms| tw_api::BucketQuery {
from_ms: Some(from_ms),
to_ms,
bucket_ms: Some(WHOLE_MS),
};
let (whole_now, whole_before) = (
whole(since, None),
whole(since - (now - since), Some(since)),
whole(since, until),
whole(since - (end - since), Some(since)),
);
let (
summary,
Expand All @@ -91,6 +97,7 @@ pub async fn dashboard(
prev,
ttft,
prev_ttft,
quotas,
) = tokio::join!(
c.call::<ep::Summary>(&[], &win),
c.call::<ep::Latency>(&[], &win),
Expand All @@ -106,6 +113,7 @@ pub async fn dashboard(
c.call::<ep::Summary>(&[], &before),
c.call::<ep::CostBuckets>(&[], &whole_now),
c.call::<ep::CostBuckets>(&[], &whole_before),
c.call::<ep::Quota>(&[], &()),
);
Ok(Dashboard {
summary: summary.map_err(text)?,
Expand All @@ -126,6 +134,8 @@ pub async fn dashboard(
prev: prev.ok(),
ttft: ttft.ok().map(|b| Percentiles::of(&b)),
prev_ttft: prev_ttft.ok().map(|b| Percentiles::of(&b)),
// 此刻的订阅额度,不跟时间窗走。拿不到同样是 `None`:那一块不画,不是「没有额度」
quotas: quotas.ok(),
since_ms: since,
})
}
Expand Down Expand Up @@ -163,6 +173,9 @@ pub struct Dashboard {
ttft: Option<Percentiles>,
/// 上一个等长区间的,首 token 那张卡片的环比用
prev_ttft: Option<Percentiles>,
/// 订阅的额度窗口(`GET /quota`),每个上游一份:概览「额度与余额」那一块。**此刻的
/// 状态**,不按时间窗取。拿不到是 `None`
quotas: Option<Vec<tw_api::ProviderQuota>>,
/// 实际用上的时间窗起点。**原样回传** —— 界面补空桶要从它数起,而界面送来的
/// 起点在未来时(时钟被往回拨过)这里截到了现在
since_ms: i64,
Expand Down
1 change: 1 addition & 0 deletions src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const WEBVIEW_ENDPOINTS = [
"PreviewAlias",
"AliasUsage",
"RouteStats",
"UsageHistory",
"UpstreamHealth",
"Pricing",
"RefreshPricing",
Expand Down
36 changes: 35 additions & 1 deletion src/generated/tw-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand.

export const CONTROL_API_VERSION = 54;
export const CONTROL_API_VERSION = 55;

/**
* 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。
Expand Down Expand Up @@ -6257,6 +6257,38 @@ covered_since_ms: number | null,
*/
upstreams: Array<UpstreamCheckup>, };

/**
* 很长一段时间里的用量,按格合计(`GET /summary/history`)。概览上「最近一年」那张热力图用。
*
* **不从请求记录里数**:请求记录只留保留期那么多天,请求库换版本时还会整个重建。用量另按
* UTC 整点累加存着,这两样都不丢;口径和 [`Summary`] 一样(本地应答不算,费用三态分开)。
* 一个请求在它结束时加进它开始的那个整点,所以还在跑的不在里面。
*/
export type UsageHistory = {
/**
* 从什么时候起有记录,Unix 毫秒:这一刻之前的格子是「没有记录」,不是「没有用量」。
* 开始记用量历史时请求记录里已有的行补进来了,所以是那时最老那一行的时刻;那时一行都没有
* 就是开始记的那一刻。从没记过是 None。
*
* **说的是用量历史,不是逐条记录**:它定下之后不再变,请求记录过了保留期被删、请求库重建都
* 不动它。逐条记录从哪一刻起有看 [`UsageHistory::detail_since_ms`]
*/
since_ms: number | null,
/**
* 逐条记录从哪一刻起有,Unix 毫秒:请求记录里最老那一行开始的时刻(本地应答也算,口径同
* [`RouteStats::covered_since_ms`])。请求记录是空的是 None。
*
* **概览、流量页按逐条记录算**:这一刻之前的日子在用量历史里有数,在那些页上却是零 ——
* 记录过了保留期被删了、请求库重建过。它随保留期往后挪
*/
detail_since_ms: number | null,
/**
* 按给的格宽分的格子,**稀疏**(没有用量的格子不在里面),分格算法同 `/summary/buckets`
* (格子的起点是 `from + k × bucket`)。累加的单位是整点:起点不在整点上时,一个整点整个
* 落在它开始的那一格。首 token 的三项不记(None、None、0)
*/
buckets: Array<CostBucket>, };

/**
* 一次调用的用量。
*/
Expand Down Expand Up @@ -6385,6 +6417,7 @@ export const ENDPOINTS = {
CostBuckets: { method: "GET", path: "/summary/buckets", params: [], format: "json" },
CostBucketsBy: { method: "GET", path: "/summary/buckets/by", params: [], format: "json" },
CostBy: { method: "GET", path: "/summary/by", params: [], format: "json" },
UsageHistory: { method: "GET", path: "/summary/history", params: [], format: "json" },
RouteStats: { method: "GET", path: "/summary/routes", params: [], format: "json" },
History: { method: "GET", path: "/history", params: [], format: "json" },
HistorySearch: { method: "POST", path: "/history/search", params: [], format: "json" },
Expand Down Expand Up @@ -6537,6 +6570,7 @@ export type Endpoints = {
CostBuckets: { req: BucketQuery; res: Array<CostBucket> };
CostBucketsBy: { req: BucketGroupQuery; res: Array<CostBucketGroup> };
CostBy: { req: GroupQuery; res: Array<CostGroup> };
UsageHistory: { req: BucketQuery; res: UsageHistory };
RouteStats: { req: Window; res: RouteStats };
History: { req: ListQuery; res: Array<HistoryRow> };
HistorySearch: { req: HistorySearchQuery; res: HistorySearchPage };
Expand Down
Loading
Loading