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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,27 @@ before the client runs them.
and a restore always available; Cursor, Continue and Antigravity CLI come
with instructions. Switching upstreams then happens in the gateway alone.
- **Protection against relays.** A relay sees every request in full and can
rewrite every answer. Outbound redaction swaps API keys, private keys, JWTs
and connection-string passwords for placeholders before a request leaves, so
the relay never holds the real values. When an answer carries a tool call
rewrite every answer. Outbound redaction swaps API keys, private keys, JWTs,
connection-string passwords, Chinese resident ID numbers and bank card numbers
for placeholders before a request leaves, so the relay never holds the real
values. When an answer carries a tool call
that downloads and runs code, sends out environment variables or credential
files, reads private keys or installs a startup item or scheduled job,
tool-call inspection cuts the answer off before the client can run it.
Hidden characters and prompt injection can be refused as well. The
protections start in Observe and switch to Enforce one by one.
- **Upstream check-up.** Each upstream is compared with the others serving the
same model: answers naming a different model, reported input well above or
below theirs and low prompt-cache reads are marked, with sample sizes.
- **MCP servers, skills and hooks, scanned.** The MCP servers of ten clients
side by side, with third-party servers marked, and a scan of client
configuration, skills, hooks and project instructions for hidden characters,
prompt injection, dangerous commands and overly broad permissions.
- **Every request traceable.** The rule a request matched, each upstream it
tried, any conversion between API formats and how its cost was calculated;
a finished request can be replayed against another upstream and compared
side by side.
side by side. The whole history can be searched, including the text of
requests and answers.
- **Routing and failover.** Rules by model, tools, images, extended thinking
and more. When an upstream fails before the answer begins the next one takes
over, and each session stays on one upstream so its prompt cache keeps
Expand Down
5 changes: 3 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与
## 要点

- **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。
- **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT 与连接串口令换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答;隐藏字符与提示注入也可以直接拒绝。各项防护出厂只记录,逐项切换到拦截即可生效。
- **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT、连接串口令、身份证号与银行卡号换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答;隐藏字符与提示注入也可以直接拒绝。各项防护出厂只记录,逐项切换到拦截即可生效。
- **上游体检。** 每个上游都与服务同一模型的其他上游对照:回答中的模型名与发出的不同、报告的输入明显偏多或偏少、提示缓存读取偏低,都会标出,并附样本数。
- **扫描 MCP、技能与钩子。** 十款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。
- **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。
- **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。全部请求记录都可以搜索,包括请求与回答的内容。
- **按规则分流,失败自动换。** 按模型、工具、图片、扩展思考等条件分流。回答开始前上游出错时换用下一个,同一会话固定使用同一上游,提示缓存保持有效。标题生成等辅助请求可在本地应答。
- **多种上游,接口互转。** API 密钥、Amazon Bedrock、ChatGPT 与 Z.ai 账号、OpenRouter 等中转服务与本机模型均可作为上游,Anthropic、OpenAI、Gemini 接口之间自动转换。
- **费用如实计算。** 估算的金额单独标注,无法计价的请求单独计数,不按零计入。
Expand Down
18 changes: 17 additions & 1 deletion scripts/shots/mock/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ import {
security,
keys,
} from "./config";
import { HISTORY, IN_FLIGHT, SEC_EVENTS, bodies, lastSeen, routeStats, sessionView, sessions, turns, unpricedModels } from "./traffic";
import {
HISTORY,
IN_FLIGHT,
SEC_EVENTS,
bodies,
historySearch,
lastSeen,
routeStats,
sessionView,
sessions,
turns,
unpricedModels,
upstreamHealth,
} from "./traffic";
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 @@ -80,6 +93,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler<N> } = {
.reverse(),
);
},
HistorySearch: (req) => historySearch(req),
RequestDetail: (_req, [id]) => {
const h = HISTORY.find((x) => x.id === Number(id)) ?? notFound(`Request #${id}`);
return { row: clone(h), ...bodies(h), in_flight: false };
Expand Down Expand Up @@ -120,6 +134,8 @@ export const CORE: { [N in WebviewEndpoint]: Handler<N> } = {
DeleteGroup: refuse,
KnownModels: () => knownModels(),
// 没给时间窗就是今天:本地零点到现在(`Window` 的默认)
// 没给时间窗就是最近 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()),

Pricing: () => pricing(unpricedModels()),
Expand Down
139 changes: 138 additions & 1 deletion scripts/shots/mock/traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
// 更早的由带种子的随机数铺开,每次拍出来都一样。
import type {
AttemptView,
CacheTally,
ContentHit,
CostBucket,
CostBucketGroup,
CostGroup,
Dialect,
HistoryRow,
HistorySearchPage,
HistorySearchQuery,
InFlightRequest,
LatencyView,
TokenRateView,
Expand All @@ -27,10 +31,12 @@ import type {
SessionView,
Summary,
TurnView,
UpstreamCheckup,
UpstreamHealth,
} from "@/types";
import type { Dashboard } from "@/types";
import { AS_OF, N, priceFor, priceSource } from "./config";
import { DAY, HOUR, MIN, NOW, SEC, L, msg, rng } from "./util";
import { DAY, HOUR, MIN, NOW, SEC, L, clone, msg, rng } from "./util";
import OV_EN from "../core/en/overview.json";

// ───────────────────────────────────────── 谁在发请求
Expand Down Expand Up @@ -885,3 +891,134 @@ export function bodies(h: HistoryRow) {
const text = JSON.stringify({ model: h.model, stream: true, messages: [{ role: "user", content: L("修复登录页的表单校验", "Fix the form validation on the sign-in page") }] }, null, 2);
return { request_body: { text, original_len: text.length, truncated: false }, response_body: null };
}

// ───────────────────────────────────────── 在整份记录里搜索、上游体检(core 0.57)

/**
* `POST /history/search`,照 tw-store 的 `search::run`:新的在前;按记录对(路径、密钥、应用、来源、
* 上游、模型、失败原因和它的码、本地应答那句话),按内容找时再看请求和回答的正文。截图的记录
* 不多,不设读正文的量,一页凑满或者找完就停
*/
export function historySearch(req: HistorySearchQuery): HistorySearchPage {
const q = (req.q ?? "").trim().toLowerCase();
const limit = Math.min(Math.max(req.limit ?? 100, 1), 500);
const b = req.before ?? null;
const codes = req.error_codes ?? [];
const reads = req.content === true && q !== "";
const byRecord = (h: HistoryRow) =>
q === "" ||
(h.local && req.local_matches === true) ||
(h.error != null && codes.includes(h.error.code)) ||
[h.path, h.client, h.client_hint ?? "", h.peer ?? "", h.local ? "" : h.provider, h.model, h.error?.text ?? ""].some((s) =>
s.toLowerCase().includes(q),
);
const rows = HISTORY.filter(
(h) =>
(req.from_ms == null || h.at_ms >= req.from_ms) &&
(req.to_ms == null || h.at_ms <= req.to_ms) &&
(!b || h.at_ms < b.at_ms || (h.at_ms === b.at_ms && h.id < b.id)) &&
(!req.failed || h.error != null) &&
(!req.unpriced || (unpriced(h) && !h.cancelled && h.input_tokens != null)) &&
(!req.client || h.client === req.client) &&
(!req.provider || (!h.local && h.provider === req.provider)) &&
(!req.model || h.model === req.model),
).sort((x, y) => y.at_ms - x.at_ms || y.id - x.id);
const out: HistoryRow[] = [];
const hits: ContentHit[] = [];
for (const h of rows) {
if (byRecord(h)) out.push(h);
else if (reads && !h.local) {
// 截图的记录只带请求的正文(`bodies`),回答那一边没有
const text = bodies(h).request_body.text;
const at = text.toLowerCase().indexOf(q);
if (at >= 0) {
const from = Math.max(0, at - 40);
const to = Math.min(text.length, at + q.length + 40);
const flat = (s: string) => s.replace(/\s+/g, " ");
hits.push({
id: h.id,
side: "request",
before: (from > 0 ? "…" : "") + flat(text.slice(from, at)),
matched: text.slice(at, at + q.length),
after: flat(text.slice(at + q.length, to)) + (to < text.length ? "…" : ""),
});
out.push(h);
}
}
if (out.length >= limit) {
const last = out[out.length - 1]!;
return { rows: clone(out), hits, next: { at_ms: last.at_ms, id: last.id }, bodies_since_ms: reads ? oldestDay() : null, stopped: "full" };
}
}
return { rows: clone(out), hits, next: null, bodies_since_ms: reads ? oldestDay() : null, stopped: "end" };
}

/** 正文最早留到哪一天的零点(UTC):截图的记录都带着正文,就是最老那条请求的那一天 */
function oldestDay(): number | null {
if (HISTORY.length === 0) return null;
const d = new Date(Math.min(...HISTORY.map((h) => h.at_ms)));
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}

/**
* `GET /upstreams/health`,照 tw-store 的 `upstream_health`:本地应答的、一家都没去的不算;请求数
* 不含取消的。截图的记录里回答写的模型名都和发出去的一样,报的输入就按本地估算记(比值 1),
* 缓存按同一会话里的轮次数
*/
export function upstreamHealth(from: number, to: number): UpstreamHealth {
const rows = rowsBetween(from, to).filter((h) => !h.local && h.provider);
const by = new Map<string, HistoryRow[]>();
for (const h of rows) by.set(h.provider, [...(by.get(h.provider) ?? []), h]);
const ok = (h: HistoryRow) => !h.error && !h.cancelled && h.input_tokens != null;
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)]!;
const tally = (xs: HistoryRow[]): CacheTally => {
const t: CacheTally = { turns: 0, zero_read_turns: 0, input_tokens: 0, cache_read_tokens: 0 };
for (const h of xs.filter((x) => ok(x) && x.session)) {
t.turns += 1;
t.input_tokens += (h.input_tokens ?? 0) + (h.cache_read_tokens ?? 0) + (h.cache_write_tokens ?? 0);
t.cache_read_tokens += h.cache_read_tokens ?? 0;
if (!h.cache_read_tokens) t.zero_read_turns += 1;
}
return t;
};
const upstreams: UpstreamCheckup[] = [...by.entries()].map(([p, xs]) => {
const done = xs.filter((h) => !h.cancelled);
const models = [...new Set(xs.filter(ok).map((h) => h.model))];
const servedBy = (m: string, who: (o: string) => boolean) =>
[...by.entries()].filter(([o]) => who(o)).flatMap(([, ys]) => ys.filter((h) => ok(h) && h.model === m));
const ttft = xs.flatMap((h) => (h.ttft_ms != null ? [h.ttft_ms] : []));
const tps = xs.flatMap((h) => (h.tokens_per_sec != null ? [h.tokens_per_sec] : []));
const samples = xs.filter(ok).length;
return {
upstream: p,
requests: done.length,
failed: done.filter((h) => h.error).length,
cancelled: xs.length - done.length,
models: { named: done.filter((h) => h.model).length, differed: 0, examples: [] },
input: {
all: samples ? { median: 1, samples } : null,
by_model: models.map((m) => {
const others = servedBy(m, (o) => o !== p);
const k = new Set(others.map((h) => h.provider)).size;
return { model: m, here: { median: 1, samples: servedBy(m, (o) => o === p).length }, others: k ? { median: 1, samples: others.length } : null, other_upstreams: k };
}),
},
cache: {
all: tally(xs),
by_model: models.flatMap((m) => {
const here = tally(servedBy(m, (o) => o === p));
if (here.turns === 0) return [];
const others = servedBy(m, (o) => o !== p);
const k = new Set(others.map((h) => h.provider)).size;
return [{ model: m, here, others: k ? tally(others) : null, other_upstreams: k }];
}),
},
ttft_ms: ttft.length ? { p50: median(ttft), samples: ttft.length } : null,
tokens_per_sec: tps.length ? { p50: median(tps), samples: tps.length } : null,
};
});
upstreams.sort((a, b) => b.requests - a.requests);
const oldest = HISTORY.length ? Math.min(...HISTORY.map((h) => h.at_ms)) : null;
const covered = oldest == null ? null : Math.max(oldest, from);
return { from_ms: from, to_ms: to, covered_since_ms: covered != null && covered < to ? covered : null, upstreams };
}
44 changes: 22 additions & 22 deletions src-tauri/Cargo.lock

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

Loading
Loading