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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ before the client runs them.
## Highlights

- **Connect once, switch freely.** Claude Code, Claude Desktop, Codex,
opencode, Zed, Aider and DeepSeek Harness are pointed at the gateway in one
step, with the change previewed, the original file backed up and a restore
always available; Cursor, Continue and Antigravity CLI come with
instructions. Switching upstreams then happens in the gateway alone.
opencode, Pi, oh-my-pi, Zed, Aider and DeepSeek Harness are pointed at the
gateway in one step, with the change previewed, the original file backed up
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
Expand All @@ -46,7 +46,7 @@ before the client runs them.
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.
- **MCP servers, skills and hooks, scanned.** The MCP servers of eight clients
- **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.
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与

## 要点

- **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。
- **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。
- **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT 与连接串口令换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答;隐藏字符与提示注入也可以直接拒绝。各项防护出厂只记录,逐项切换到拦截即可生效。
- **扫描 MCP、技能与钩子。** 八款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。
- **扫描 MCP、技能与钩子。** 十款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。
- **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。
- **按规则分流,失败自动换。** 按模型、工具、图片、扩展思考等条件分流。回答开始前上游出错时换用下一个,同一会话固定使用同一上游,提示缓存保持有效。标题生成等辅助请求可在本地应答。
- **多种上游,接口互转。** API 密钥、Amazon Bedrock、ChatGPT 与 Z.ai 账号、OpenRouter 等中转服务与本机模型均可作为上游,Anthropic、OpenAI、Gemini 接口之间自动转换。
Expand Down
71 changes: 70 additions & 1 deletion scripts/shots/mock/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ function setup(id: string, path: string): ManualSetup {
secret("refs.THINKWATCH_API_KEY"),
],
};
case "pi":
case "omp":
return {
steps: [file(path)],
endpoint: v1(),
fields: [
...(id === "pi" ? [set("providers.thinkwatch.name", "ThinkWatch")] : []),
set("providers.thinkwatch.baseUrl", v1()),
set("providers.thinkwatch.api", "openai-completions"),
secret("providers.thinkwatch.apiKey"),
...(id === "omp" ? [set("providers.thinkwatch.auth", "apiKey")] : []),
set(
"providers.thinkwatch.models",
`[{id: claude-sonnet-5, api: anthropic-messages, baseUrl: ${base()}}, {id: gpt-5.5, api: openai-responses}, {id: deepseek-chat}]`,
),
],
};
default:
return { steps: [file(path)], endpoint: v1(), fields: [set("openai-api-base", v1()), secret("openai-api-key")] };
}
Expand Down Expand Up @@ -257,6 +274,10 @@ function clientsNow(): DetectedClient[] {
"adopt.cost.dsh.every_entry",
"The web app, the desktop app and headless runs all go through the gateway, without a restart.",
),
msg(
"adopt.cost.dsh.account_direct",
"Models used through a DeepSeek account signed in to the desktop app go straight to api.deepseek.com and do not pass through the gateway.",
),
msg("adopt.cost.dsh.web_search", "Web search still goes straight to DeepSeek rather than through the gateway."),
msg(
"adopt.cost.dsh.settings_page",
Expand All @@ -268,6 +289,32 @@ function clientsNow(): DetectedClient[] {
),
],
}),
detected({
id: "pi",
name: "Pi",
path: "~/.pi/agent/models.json",
installed: false,
has_config: false,
costs: [
msg(
"adopt.cost.pi.default_model",
"The default model stays as it is; ThinkWatch models are chosen in /model, where Ctrl+S makes one the default.",
),
],
}),
detected({
id: "omp",
name: "oh-my-pi",
path: "~/.omp/agent/models.yml",
installed: false,
has_config: false,
costs: [
msg(
"adopt.cost.omp.default_model",
"The default model stays as it is; ThinkWatch models are chosen in /model, where assigning the default role makes one the default.",
),
],
}),
];
}

Expand Down Expand Up @@ -367,7 +414,7 @@ export function plan(id: string, restore: boolean): PlanView {

// ───────────────────────────────────────── MCP 与扫描

/** MCP 能写进哪几个客户端(tw-adopt mcp.rs 的 `targets`,一个不少)。Zed、Antigravity CLI 与 DeepSeek Harness 这台机器上没有,画在矩阵下方 */
/** MCP 能写进哪几个客户端(tw-adopt mcp.rs 的 `targets`,一个不少)。Zed、Antigravity CLI、Pi、oh-my-pi 与 DeepSeek Harness 这台机器上没有,画在矩阵下方 */
export function mcpTargets(): McpTargetView[] {
return [
{ client: "claude-code", name: "Claude Code", path: "~/.claude.json", copyable: true, why_not: null, movable: true, present: true },
Expand Down Expand Up @@ -404,6 +451,28 @@ export function mcpTargets(): McpTargetView[] {
why_not: msg("adopt.mcp.zed_structure", "Zed's context servers use a different structure and do not take the command/args form"),
movable: true, present: false,
},
{
client: "pi",
name: "Pi",
path: "~/.pi/agent/mcp.json",
copyable: false,
why_not: msg(
"adopt.mcp.unverified_format",
"this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration",
),
movable: true, present: false,
},
{
client: "omp",
name: "oh-my-pi",
path: "~/.omp/agent/mcp.json",
copyable: false,
why_not: msg(
"adopt.mcp.unverified_format",
"this client's MCP configuration format is not verified yet, and writing to it could leave the client unable to read its own configuration",
),
movable: true, present: false,
},
{
client: "dsh",
name: "DeepSeek Harness",
Expand Down
167 changes: 162 additions & 5 deletions src-tauri/crates/tw-adopt/src/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ pub struct Client {
/// 密钥不在配置文件里、要在客户端自己的界面里填时,手动配置多出来的那一步
/// (「码,英文原句」)。只有 Zed 是这样:它的密钥走自己的凭据存储。
pub key_elsewhere: Option<(&'static str, &'static str)>,
/// 模型清单要写进它的配置(opencode):接管时写一份网关对这把密钥答的清单,
/// 上游或路由变了之后客户端页提示更新。别的客户端自己去问网关。
/// 模型清单要写进它的配置(opencode、Pi、oh-my-pi):接管时写一份网关对这把密钥答的
/// 清单,上游或路由变了之后客户端页提示更新。别的客户端自己去问网关。
pub writes_models: bool,
/// 它自己会重读配置,改完不用重启。**装着的版本说了算**,见 [`Client::here`]
pub reloads: bool,
Expand Down Expand Up @@ -349,7 +349,10 @@ pub fn adoptable() -> Vec<Client> {
verified: Verified::Measured,
marker: &[Loc::Home(".codex")],
process: &["codex"],
env_vars: &["OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME"],
// **没有 `OPENAI_BASE_URL`**:Codex 2026-04 起不再读它(openai/codex 4b8bab6,
// 换成了 config.toml 里的 `openai_base_url`)。shell 里还 export 着它的用户,
// 诊断再说「这一行盖住了接管写的值」就是误报
env_vars: &["OPENAI_API_KEY", "CODEX_HOME"],
key_elsewhere: None,
writes_models: false,
reloads: false,
Expand Down Expand Up @@ -462,6 +465,12 @@ pub fn adoptable() -> Vec<Client> {
code!("adopt.cost.dsh.every_entry"),
"The web app, the desktop app and headless runs all go through the gateway, without a restart.",
),
// 桌面版能用 DeepSeek 账号登录:那条线路是另一行插件(`llm-deepseek-account`),
// 令牌只交给 DeepSeek 自己的地址(`resolveToken` 认目的地),改不到网关上来
(
code!("adopt.cost.dsh.account_direct"),
"Models used through a DeepSeek account signed in to the desktop app go straight to api.deepseek.com and do not pass through the gateway.",
),
(
code!("adopt.cost.dsh.web_search"),
"Web search still goes straight to DeepSeek rather than through the gateway.",
Expand All @@ -476,6 +485,8 @@ pub fn adoptable() -> Vec<Client> {
),
],
verified: Verified::FieldsOnly,
// 桌面版装好、还没打开过时家目录还不在:那时认它的应用本身,见
// [`Client::installed`]
marker: &[crate::paths::DSH_DIR],
process: &["dsh"],
// 继承下来的环境变量压过凭据文件:shell 里 export 了同名的,
Expand All @@ -487,6 +498,73 @@ pub fn adoptable() -> Vec<Client> {
config_beats_env: false,
custom_config: None,
},
// Pi(earendil-works/pi,原来的 badlogic/pi-mono)。写的是 `models.json` 里一个
// 自己的 provider 和模型清单,见 `crate::pi`。
//
// 字段名照 Pi 0.99.2 的源码(`model-config.ts` 的 schema、`provider-composer.ts`
// 的 `modelFromJson`),没有在本机实跑过
Client {
id: "pi",
name: "Pi",
config: &[crate::paths::PI_MODELS],
format: Format::Json,
// 打开 /model 就重读 models.json(`model-runtime.ts` 的 `refresh`),跑着的 Pi
// 不用重启
takes_effect: TakesEffect::Immediately,
shadowed_by: &[],
// **默认模型不动**(见 `crate::pi`):接管之后它还用原来那个,要说清去哪儿选
costs: &[(
code!("adopt.cost.pi.default_model"),
"The default model stays as it is; ThinkWatch models are chosen in /model, where Ctrl+S makes one the default.",
)],
verified: Verified::FieldsOnly,
marker: &[crate::paths::PI_DIR],
// npm 装的 Pi 进程名就是 node,认不出它;它自己重读配置(`reloads`),诊断用不着
// 这一项
process: &[],
// 它把整个配置目录挪走:shell 里 export 了它,Pi 读的就不是这里写的那一份
env_vars: &["PI_CODING_AGENT_DIR"],
key_elsewhere: None,
writes_models: true,
reloads: true,
config_beats_env: false,
custom_config: None,
},
// oh-my-pi(`omp`,can1357/oh-my-pi),Pi 的分支:同样的 provider 写法,换成了
// YAML 的 `models.yml`,而且必须写明 `auth: apiKey`(见 `crate::pi::edits`)。
//
// 字段名照它的源码(`models-config-schema-bundle.ts`、`custom-models.ts`)和
// docs/models.md,没有在本机实跑过
Client {
id: "omp",
name: "oh-my-pi",
config: crate::paths::OMP_MODELS,
format: Format::Yaml,
// 模型选择器打开时,models.yml 改过就重读(`model-picker.ts` 的
// `refreshIfStale`)
takes_effect: TakesEffect::Immediately,
shadowed_by: &[],
costs: &[(
code!("adopt.cost.omp.default_model"),
"The default model stays as it is; ThinkWatch models are chosen in /model, where assigning the default role makes one the default.",
)],
verified: Verified::FieldsOnly,
marker: &[crate::paths::OMP_DIR],
process: &["omp"],
// 这几个都换它读的目录:前两个挪整个配置目录,后两个选一个具名 profile
// (`~/.omp/profiles/<名>/agent`)
env_vars: &[
"PI_CODING_AGENT_DIR",
"PI_CONFIG_DIR",
"OMP_PROFILE",
"PI_PROFILE",
],
key_elsewhere: None,
writes_models: true,
reloads: true,
config_beats_env: false,
custom_config: None,
},
// 官方的「第三方推理」模式。**一次改四个文件**,写哪几个、为什么,见
// `crate::desktop`;这里的 `config` 是其中的主文件,我们在它配置库里的那一份。
Client {
Expand Down Expand Up @@ -888,6 +966,9 @@ pub fn edits(client: &Client, gw: &Gateway) -> Vec<Edit> {
Val::s(DSH_KEY_REF),
),
],
// 一个自己的 provider 加上模型清单,每个模型挑它本家的 API。见 `crate::pi`
"pi" => crate::pi::edits(gw, crate::pi::Flavor::Pi),
"omp" => crate::pi::edits(gw, crate::pi::Flavor::Omp),
_ => Vec::new(),
}
}
Expand Down Expand Up @@ -968,6 +1049,32 @@ impl Client {
self
}

/// 这台机器上装了它:痕迹在([`Client::marker`]),或者找得到它的桌面应用。
///
/// 后一种只有 DeepSeek Harness:桌面版装好、还没打开过的时候,它的家目录还不在,
/// 可它确实装了([`crate::paths::dsh_desktop_app`])。
pub fn installed(&self, home: &std::path::Path) -> bool {
self.marker.iter().any(|m| m.resolve(home).exists())
|| (self.id == "dsh" && crate::paths::dsh_desktop_app(home).is_some())
}

/// 配置里此刻写着的模型(只有 [`Client::writes_models`] 的客户端有)。没有那一条
/// provider 就是 `None`。
pub fn models_in(&self, text: &str) -> Option<Vec<String>> {
match self.id {
"opencode" => crate::opencode::models_in(text),
id => crate::pi::models_in(text, crate::pi::Flavor::of(id)?),
}
}

/// 几个候选文件([`Client::config`])里它只读在的第一个,不合并。
///
/// oh-my-pi 是这样:`models.yml` 一在,`models.yaml` 就整个没人读 —— 排在前面的那一份
/// 盖住的不是同名的几项,是这里写的全部。opencode 是逐层合并的,不一样。
pub fn reads_first_only(&self) -> bool {
self.id == "omp"
}

/// 手动配置的几步:打开哪个文件、写下面那几项(就是接管时写的那几项,
/// 见 [`edits`]),密钥要另外填的再加一步。
///
Expand Down Expand Up @@ -1280,10 +1387,11 @@ mod tests {
models: Vec::new(),
};
let by = |id: &str| adoptable().into_iter().find(|c| c.id == id).unwrap();
// 各要各的写法:Claude Code 和 Claude Desktop 不带 /v1,其余带
// 各要各的写法:Claude Code 和 Claude Desktop 不带 /v1,其余带(Pi 和 oh-my-pi 的
// provider 带 /v1,Claude 那几个模型自己另写不带的)
assert_eq!(by("claude-code").endpoint(&gw), "http://127.0.0.1:18790");
assert_eq!(by("claude-desktop").endpoint(&gw), "http://127.0.0.1:18790");
for id in ["codex", "opencode", "zed", "aider", "dsh"] {
for id in ["codex", "opencode", "zed", "aider", "dsh", "pi", "omp"] {
assert_eq!(by(id).endpoint(&gw), "http://127.0.0.1:18790/v1", "{id}");
}
for c in adoptable() {
Expand Down Expand Up @@ -1371,6 +1479,55 @@ mod tests {
}
}

/// Codex 2026-04 起不读 `OPENAI_BASE_URL` 了(openai/codex 4b8bab6):shell 里 export 着
/// 它不该再被说成盖住了接管写的值
#[test]
fn codex_no_longer_watches_openai_base_url() {
let codex = adoptable().into_iter().find(|c| c.id == "codex").unwrap();
assert!(!codex.env_vars.contains(&"OPENAI_BASE_URL"));
assert!(codex.env_vars.contains(&"CODEX_HOME"));
}

/// Pi 和 oh-my-pi 只写自己那一个 provider:改内置的,`/login` 的凭据就跟着发到网关去了
#[test]
fn pi_and_omp_write_a_provider_of_their_own() {
let gw = Gateway::keyed("http://127.0.0.1:8788", "tw-k", vec!["claude-x".into()]);
for id in ["pi", "omp"] {
let c = adoptable().into_iter().find(|c| c.id == id).unwrap();
for e in edits(&c, &gw) {
assert_eq!(
e.path[..2],
["providers".to_string(), PROVIDER_ID.to_string()],
"{id}:{:?}",
e.path
);
}
assert!(c.writes_models && c.reloads, "{id}");
assert_eq!(c.verified, Verified::FieldsOnly, "{id}");
}
}

/// DeepSeek Harness 桌面版装好还没打开过:家目录还不在,那一行照样算装了
#[test]
fn the_dsh_desktop_app_counts_as_dsh_installed() {
let d = tempfile::tempdir().unwrap();
let dsh = adoptable().into_iter().find(|c| c.id == "dsh").unwrap();
assert!(!dsh.installed(d.path()));
let app = if cfg!(target_os = "macos") {
"Applications/DeepSeek Harness.app"
} else if cfg!(windows) {
"AppData/Local/Programs/DeepSeek Harness/DeepSeek Harness.exe"
} else {
// Linux 版没有发布,没有可认的位置
return;
};
let p = crate::paths::under(d.path(), app);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "").unwrap();
assert!(dsh.installed(d.path()));
assert_eq!(crate::paths::dsh_desktop_app(d.path()), Some(p));
}

/// 码重了等于两句不同的话共用一条译文 —— 改其中一句,另一句会跟着
/// 变,而没有任何东西会说出来。
#[test]
Expand Down
Loading
Loading