feat(intercept): 模型兜底审批单独计量 token 并在配置页展示

兜底审批(LLM judge)调模型判定漏网命令,其 token 消耗此前无法单独查看:
usage 行要么无 worker 归属、要么被并进复用的模型 profile 桶里。

- llmrec 增加 WithWorker 上下文覆盖,reviewer 调用前标注 worker='judge',
  让审批调用的计量行可被单独筛出(复用现有 llm_usage.worker 列,无需迁移)。
- db.JudgeUsageStats 返回累计总量 + 近 N 天每日序列(WHERE worker='judge')。
- 新增 GET /api/intercept/judge/usage。
- 配置页「模型兜底审批」开关下方新增用量卡片:调用次数 / 输入 / 输出 /
  缓存读写 五项统计 + 近 30 天每日消耗迷你柱图 + 刷新。
This commit is contained in:
Autumn-27 committed 2026-10-04 02:43:26 -04:00
1 parent d0033724a6
commit 86729b65dc
8 files changed
+242 -1

No files matched your search

+96 -1
View File
@@ -51,7 +51,53 @@ import {
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { InterceptRule, InterceptAction, JudgeConfig, LLMProfile, Tool } from "@/lib/types";
import type {
InterceptAction,
InterceptRule,
JudgeConfig,
JudgeDayUsage,
JudgeUsage,
LLMProfile,
Tool,
} from "@/lib/types";
// fmtTokens 把 token 数压成紧凑写法(1.2k / 3.4M),用于审批用量统计。
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
// JudgeStat 是一块统计数字(标签 + 数值)。
function JudgeStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="mt-0.5 text-lg font-semibold tabular-nums">{value}</div>
</div>
);
}
// JudgeSparkbars 用纯 div 画近 N 天每日消耗(输入+输出)的迷你柱图,免图表库依赖。
function JudgeSparkbars({ daily }: { daily: JudgeDayUsage[] }) {
const max = Math.max(1, ...daily.map((d) => d.input_tokens + d.output_tokens));
return (
<div className="flex h-16 items-end gap-0.5">
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const h = Math.max(2, Math.round((total / max) * 100));
return (
<div
key={d.date}
title={`${d.date} · ${d.calls} 次 · ${fmtTokens(total)} tokens`}
className="min-w-[2px] flex-1 rounded-sm bg-violet-500/60 hover:bg-violet-500"
style={{ height: `${h}%` }}
/>
);
})}
</div>
);
}
// ---- tool scope ----
@@ -164,6 +210,16 @@ function JudgeCard() {
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [usage, setUsage] = React.useState<JudgeUsage | null>(null);
// 审批用量统计:失败不打断配置页,仅在开启时拉取。
const loadUsage = React.useCallback(async () => {
try {
setUsage(await api.interceptJudgeUsage(30));
} catch {
// 忽略:统计不可用不应影响配置编辑
}
}, []);
const load = React.useCallback(async () => {
setLoading(true);
@@ -182,6 +238,11 @@ function JudgeCard() {
load();
}, [load]);
// 开启后(含初次加载把开关读为 true 时)拉取审批用量统计。
React.useEffect(() => {
if (cfg.enabled) loadUsage();
}, [cfg.enabled, loadUsage]);
function patch(p: Partial<JudgeConfig>) {
setCfg((c) => ({ ...c, ...p }));
}
@@ -237,6 +298,40 @@ function JudgeCard() {
</div>
</div>
{/* 审批 Token 用量统计(全局累计,独立于各模型配置) */}
{cfg.enabled && usage && (
<Card>
<CardContent className="p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium">审批 Token 消耗</p>
<p className="text-xs text-muted-foreground">
模型兜底审批累计用量,单独计量(worker=judge),不与各模型配置的统计混在一起
</p>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={loadUsage}>
刷新
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<JudgeStat label="审批调用" value={usage.calls.toLocaleString()} />
<JudgeStat label="输入 Token" value={fmtTokens(usage.input_tokens)} />
<JudgeStat label="输出 Token" value={fmtTokens(usage.output_tokens)} />
<JudgeStat label="缓存读" value={fmtTokens(usage.cache_read_tokens)} />
<JudgeStat label="缓存写" value={fmtTokens(usage.cache_write_tokens)} />
</div>
{usage.daily.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-[10px] uppercase tracking-wider text-muted-foreground">
近 30 天每日消耗(输入 + 输出)
</p>
<JudgeSparkbars daily={usage.daily} />
</div>
)}
</CardContent>
</Card>
)}
{cfg.enabled && (
<div className="grid gap-4 lg:grid-cols-5">
{/* 左:提示词编辑器(直接展开,主区域) */}
+2
View File
@@ -53,6 +53,7 @@ import type {
InterceptPending,
InterceptRule,
JudgeConfig,
JudgeUsage,
LLMPoolStatus,
LLMProfile,
LLMRecordDetail,
@@ -1265,6 +1266,7 @@ export const api = {
// ---- intercept LLM judge (模型兜底审批,全局配置) ----
interceptGetJudgeConfig: () => get<JudgeConfig>("/intercept/judge"),
interceptSetJudgeConfig: (cfg: JudgeConfig) => put<{ ok: boolean }>("/intercept/judge", cfg),
interceptJudgeUsage: (days = 30) => get<JudgeUsage>(`/intercept/judge/usage?days=${days}`),
// ---- commands (tool execution history, any tool) ----
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
+9
View File
@@ -2464,6 +2464,15 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
ask_timeout_action: "deny",
};
if (path === "/intercept/judge" && m === "PUT") return { ok: true };
if (path === "/intercept/judge/usage" && m === "GET")
return {
calls: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
daily: [],
};
// ── 旁路提问(/btw):demo 无旁路会话 ──
// 必须显式命中:路径以 s 结尾会被下面的读兜底判成集合返回 [],items 就成了 undefined。
+16
View File
@@ -1332,6 +1332,22 @@ export interface JudgeConfig {
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
}
// JudgeUsage: 模型兜底审批(judge 通道)的累计 token 用量 + 近 N 天每日序列。
export interface JudgeDayUsage {
date: string; // YYYY-MM-DD (UTC)
calls: number;
input_tokens: number;
output_tokens: number;
}
export interface JudgeUsage {
calls: number;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
daily: JudgeDayUsage[];
}
export interface InterceptApprovalFilter {
status?: InterceptPending["status"];
decision_source?: "rule" | "model" | "unknown";