From 2010d1912126d3ea3f6cd4ede147a87091b1b98c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:54:22 +0800 Subject: [PATCH] Remove the deepseek-flags default plugin deepseek-flags ("Avoid DeepSeek request rejections") replaced a regional flag emoji that DeepSeek's API refuses. A provider that refuses certain content is for that provider to fix; core does not rewrite requests to get around it. Two default plugins remain: reply-language and wsl-paths. - Delete defaults/deepseek-flags.js, drop it from defaults::ALL, and regenerate defaults/manifests.json (UPDATE_DEFAULT_MANIFESTS=1). - Drop its gateway tests and the test-harness helper only they used. The test that a default able to rewrite tool calls turns on only through UpdatePluginConfirmed now uses wsl-paths, which also holds reply_tool_calls. - release-notes/0.58.0.md: two default plugins. No published release contained the plugin, so seeded configurations need no cleanup. Message codes, CONTROL_API_VERSION (34) and SCHEMA (25) do not change. Co-Authored-By: Claude Opus 5.5 --- crates/tw-control/tests/plugin_defaults.rs | 26 +- .../src/plugin/defaults/deepseek-flags.js | 118 --------- .../src/plugin/defaults/manifests.json | 32 --- crates/tw-gateway/src/plugin/defaults/mod.rs | 3 +- crates/tw-gateway/tests/plugin_harness/mod.rs | 11 - crates/tw-gateway/tests/plugins_defaults.rs | 232 +----------------- release-notes/0.58.0.md | 5 +- 7 files changed, 16 insertions(+), 411 deletions(-) delete mode 100644 crates/tw-gateway/src/plugin/defaults/deepseek-flags.js diff --git a/crates/tw-control/tests/plugin_defaults.rs b/crates/tw-control/tests/plugin_defaults.rs index 681fc2a..44a2fbd 100644 --- a/crates/tw-control/tests/plugin_defaults.rs +++ b/crates/tw-control/tests/plugin_defaults.rs @@ -703,10 +703,6 @@ async fn the_shipped_defaults_go_in_turned_off_without_starting_the_sandbox() { let a = b.gw.runtime().plugins.get(id).unwrap().clone(); assert!(a.ready().unwrap().dormant(), "{id}"); } - assert_eq!( - b.entry("deepseek-flags").unwrap().scope.models, - ["deepseek*"] - ); assert_eq!( b.plugin("reply-language").await["settings"], json!({"language": "简体中文"}) @@ -898,38 +894,36 @@ async fn a_cache_entry_that_does_not_match_is_ignored() { } } -/// `deepseek-flags` 改得了回答里的工具调用:网页那条路打不开它,确认过的那条打得开 +/// `wsl-paths` 改得了回答里的工具调用:网页那条路打不开它,确认过的那条打得开 #[tokio::test] -async fn deepseek_flags_turns_on_only_with_a_confirmation() { +async fn wsl_paths_turns_on_only_with_a_confirmation() { let b = bed_in("real", false); Seeder::shipped().seed(&b.mgr).await; + // 只是打开:范围和设置都是装上时的那样 let body = |base: String| { json!({"enabled": true, "on_error": "reject", - "scope": {"clients": [], "models": ["deepseek*"], "upstreams": []}, - "settings": {}, "base_version": base}) + "scope": {"clients": [], "models": [], "upstreams": []}, + "settings": {"windows_client": false}, "base_version": base}) }; let (st, v) = call( &b.app, "PUT", - "/plugins/deepseek-flags", + "/plugins/wsl-paths", Some(body(b.version().await)), ) .await; assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); assert_eq!(v["code"], "control.plugin.needs_confirmation"); - assert!(!b.entry("deepseek-flags").unwrap().enabled); + assert!(!b.entry("wsl-paths").unwrap().enabled); let (st, v) = call( &b.app, "PUT", - "/plugins/deepseek-flags/confirmed", + "/plugins/wsl-paths/confirmed", Some(body(b.version().await)), ) .await; assert_eq!(st, StatusCode::OK, "{v}"); - assert!(b.entry("deepseek-flags").unwrap().enabled); - assert_eq!( - b.plugin("deepseek-flags").await["status"], - json!({"kind": "ok"}) - ); + assert!(b.entry("wsl-paths").unwrap().enabled); + assert_eq!(b.plugin("wsl-paths").await["status"], json!({"kind": "ok"})); } diff --git a/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js b/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js deleted file mode 100644 index d648a92..0000000 --- a/crates/tw-gateway/src/plugin/defaults/deepseek-flags.js +++ /dev/null @@ -1,118 +0,0 @@ -// 避免 DeepSeek 拒收请求 -// -// DeepSeek 接口会拒收含特定地区旗帜表情的请求:模型还没运行就回 400 Content Exists Risk。 -// 这类表情一旦进入对话历史(例如工具抓回的网页、读到的文件),之后这个会话的每一次请求 -// 都会被拒收,会话就无法继续(deepseek-ai/deepseek-harness 讨论 #7310,DeepSeek Harness -// 与 OpenCode 上都能复现)。 -// -// - 请求:系统提示词和对话消息里(含工具结果、此前工具调用的参数)出现这些表情时,换成 -// 一段 ASCII 占位文字。占位文字不会自然出现,经过 JSON 转义也保持原样。 -// - 回答:回答文字和工具调用参数里出现占位文字时换回原来的表情,客户端写出的文件里仍是 -// 原样。逐段模式下,末尾可能是占位文字开头的几个字先扣住,等下一段到了再判断。 -// -// 换哪些字符、换成什么写死在这里,没有设置项:插件持有 reply.tool_calls,替换不能被设置 -// 引向别处。同样的输入每次换出同样的结果,上游的提示词缓存照常命中;请求里没有这些表情时 -// 原样发出,一个字节都不动。 -// -// 适用范围默认是发往上游的模型名以 deepseek 开头的请求(路由改写模型名之后的那个名字), -// 客户端用别的名字、经路由转到 DeepSeek 的请求也在范围内。 -// -// 思考内容只读,其中的表情换不掉。 -// -// 权限:system、messages(请求一侧替换),reply.text、reply.tool_calls(回答一侧换回)。 - -export const manifest = { - name: "Avoid DeepSeek request rejections", - api: 1, - description: - "DeepSeek's API rejects requests that contain a certain regional flag emoji with 400 Content Exists Risk, and the whole conversation then stays stuck. This plugin replaces such emoji with placeholder text before sending and puts them back in answers and tool calls.", - permissions: ["system", "messages", "reply.text", "reply.tool_calls"], - match: { models: ["deepseek*"] }, - reply: "stream", -}; - -// 被拒收的字符序列,和各自的占位文字 -const SEQUENCES = [ - // U+1F1F9 U+1F1FC - { chars: String.fromCodePoint(0x1f1f9, 0x1f1fc), placeholder: "[[emoji:1F1F9-1F1FC]]" }, -]; - -function hide(s) { - let out = s; - for (const { chars, placeholder } of SEQUENCES) out = out.replaceAll(chars, placeholder); - return out; -} - -function reveal(s) { - let out = s; - for (const { chars, placeholder } of SEQUENCES) out = out.replaceAll(placeholder, chars); - return out; -} - -// JSON 值里的每个字符串(连同对象的键) -function deep(value, f) { - if (typeof value === "string") return f(value); - if (Array.isArray(value)) return value.map((item) => deep(item, f)); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([k, v]) => [f(k), deep(v, f)])); - } - return value; -} - -const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); - -export function onRequest(req) { - let changed = false; - const fix = (s) => { - const next = hide(s); - if (next !== s) changed = true; - return next; - }; - req.system = fix(req.system); - for (const m of req.messages) { - for (const p of m.parts) { - if (p.type === "text" || p.type === "tool_result") { - p.text = fix(p.text); - } else if (p.type === "tool_call") { - const input = deep(p.input, hide); - if (!same(input, p.input)) { - p.input = input; - changed = true; - } - } - } - } - // 没有要换的就不返回:请求原样发出 - return changed ? req : undefined; -} - -// 同一个回答里的几次调用共用一个实例:held 是上一段末尾扣住的、可能是占位文字开头的那几个字 -let held = ""; - -export function onReplyText(text) { - const s = reveal(held + text); - let keep = 0; - for (const { placeholder } of SEQUENCES) { - for (let k = Math.min(placeholder.length - 1, s.length); k > keep; k--) { - if (placeholder.startsWith(s.slice(s.length - k))) { - keep = k; - break; - } - } - } - held = s.slice(s.length - keep); - const out = s.slice(0, s.length - keep); - return out === text ? undefined : out; -} - -export function onReplyTextEnd() { - const out = held; - held = ""; - return out === "" ? undefined : out; -} - -export function onToolCall(call) { - const input = deep(call.input, reveal); - if (same(input, call.input)) return undefined; - return { id: call.id, name: call.name, input }; -} diff --git a/crates/tw-gateway/src/plugin/defaults/manifests.json b/crates/tw-gateway/src/plugin/defaults/manifests.json index 4c456de..02810b6 100644 --- a/crates/tw-gateway/src/plugin/defaults/manifests.json +++ b/crates/tw-gateway/src/plugin/defaults/manifests.json @@ -1,36 +1,4 @@ { - "deepseek-flags": { - "manifest": { - "api": 1, - "description": "DeepSeek's API rejects requests that contain a certain regional flag emoji with 400 Content Exists Risk, and the whole conversation then stays stuck. This plugin replaces such emoji with placeholder text before sending and puts them back in answers and tool calls.", - "hooks": { - "reply_text": true, - "reply_text_end": true, - "request": true, - "tool_call": true - }, - "name": "Avoid DeepSeek request rejections", - "permissions": [ - "system", - "messages", - "reply_text", - "reply_tool_calls" - ], - "reply_mode": "stream", - "requests": [ - "conversation" - ], - "scope": { - "clients": [], - "models": [ - "deepseek*" - ], - "upstreams": [] - }, - "settings": [] - }, - "sha256": "96a1069558726008bb19f39b09288585634570af55e02eec6194d4a50bee4327" - }, "reply-language": { "manifest": { "api": 1, diff --git a/crates/tw-gateway/src/plugin/defaults/mod.rs b/crates/tw-gateway/src/plugin/defaults/mod.rs index 254675a..8fe941e 100644 --- a/crates/tw-gateway/src/plugin/defaults/mod.rs +++ b/crates/tw-gateway/src/plugin/defaults/mod.rs @@ -23,7 +23,6 @@ pub const ALL: &[(&str, &str)] = &[ ("reply-language", include_str!("reply-language.js")), ("wsl-paths", include_str!("wsl-paths.js")), - ("deepseek-flags", include_str!("deepseek-flags.js")), ]; /// 每个默认插件预先算好的 manifest:id → `{ sha256, manifest }`,`sha256` 是生成时那份 @@ -52,7 +51,7 @@ mod tests { #[test] fn the_list_is_the_agreed_set() { let ids: Vec<&str> = ALL.iter().map(|(id, _)| *id).collect(); - assert_eq!(ids, ["reply-language", "wsl-paths", "deepseek-flags"]); + assert_eq!(ids, ["reply-language", "wsl-paths"]); } /// 每个 id 都装得进配置:写法对、不是控制面占用的词、不重复 diff --git a/crates/tw-gateway/tests/plugin_harness/mod.rs b/crates/tw-gateway/tests/plugin_harness/mod.rs index b70dd5a..7d4795b 100644 --- a/crates/tw-gateway/tests/plugin_harness/mod.rs +++ b/crates/tw-gateway/tests/plugin_harness/mod.rs @@ -639,17 +639,6 @@ impl Gateway { .collect() } - /// 这个插件在某一种钩子(`request` / `reply`)上每次运行的结局,按先后 - pub fn outcomes_of(&self, id: &str, hook: &str) -> Vec { - self.runs - .lock() - .unwrap() - .iter() - .filter(|r| r.run.plugin_id == id && r.run.hook.slug() == hook) - .map(|r| r.run.outcome.slug().to_string()) - .collect() - } - /// 这个插件每次出错、被拒时记下的消息码,按先后 pub fn error_codes(&self, id: &str) -> Vec { self.runs diff --git a/crates/tw-gateway/tests/plugins_defaults.rs b/crates/tw-gateway/tests/plugins_defaults.rs index bbf0463..d21b313 100644 --- a/crates/tw-gateway/tests/plugins_defaults.rs +++ b/crates/tw-gateway/tests/plugins_defaults.rs @@ -7,32 +7,17 @@ mod plugin_harness; use plugin_harness::*; -use serde_json::{Value, json}; +use serde_json::json; use tw_config::Security; use tw_gateway::plugin::engine::Engine; const REPLY_LANGUAGE: &str = include_str!("../src/plugin/defaults/reply-language.js"); const WSL_PATHS: &str = include_str!("../src/plugin/defaults/wsl-paths.js"); -const DEEPSEEK_FLAGS: &str = include_str!("../src/plugin/defaults/deepseek-flags.js"); -const DEFAULTS: [(&str, &str); 3] = [ - ("reply-language", REPLY_LANGUAGE), - ("wsl-paths", WSL_PATHS), - ("deepseek-flags", DEEPSEEK_FLAGS), -]; +const DEFAULTS: [(&str, &str); 2] = [("reply-language", REPLY_LANGUAGE), ("wsl-paths", WSL_PATHS)]; const MODEL: &str = "claude-sonnet-4-5"; -/// DeepSeek 拒收的那一对区域指示符(U+1F1F9 U+1F1FC) -fn flag() -> String { - [0x1F1F9u32, 0x1F1FC] - .iter() - .map(|c| char::from_u32(*c).unwrap()) - .collect() -} - -const FLAG_PLACEHOLDER: &str = "[[emoji:1F1F9-1F1FC]]"; - async fn ask( gw: &Gateway, fmt: Fmt, @@ -64,7 +49,7 @@ fn every_default_loads_with_its_fixed_permissions_and_settings() { &'static [P], &'static [(&'static str, K)], ); - let want: [Expected; 3] = [ + let want: [Expected; 2] = [ ( "reply-language", "Answer in a chosen language", @@ -77,12 +62,6 @@ fn every_default_loads_with_its_fixed_permissions_and_settings() { &[P::Messages, P::ReplyToolCalls], &[("windows_client", K::Boolean)], ), - ( - "deepseek-flags", - "Avoid DeepSeek request rejections", - &[P::System, P::Messages, P::ReplyText, P::ReplyToolCalls], - &[], - ), ]; let engine = tw_gateway::plugin::sandbox::Sandbox; for (id, name, perms, settings) in want { @@ -111,8 +90,6 @@ fn every_default_loads_with_its_fixed_permissions_and_settings() { ); } } - let ds = engine.load(DEEPSEEK_FLAGS.as_bytes()).unwrap(); - assert_eq!(ds.manifest().scope.models, ["deepseek*"]); } #[test] @@ -268,206 +245,3 @@ async fn wsl_paths_rewrites_earlier_tool_calls_but_not_tool_results_in_every_for ); } } - -// ── DeepSeek 拒收的旗帜表情 ───────────────────────────────────── - -fn poisoned_history() -> Vec { - let f = flag(); - vec![ - Turn::User(format!("这个网页上有 {f},帮我看看")), - Turn::Call { - id: "call_1".into(), - name: "Fetch".into(), - input: json!({ "url": "https://example.com", "note": format!("找 {f}") }), - }, - Turn::Result { - id: "call_1".into(), - name: "Fetch".into(), - text: format!("旗帜 {f} 在页脚"), - }, - Turn::Assistant(format!("页脚里有一个 {f}。")), - Turn::User("继续".into()), - ] -} - -#[tokio::test] -async fn deepseek_flags_unsticks_a_poisoned_history_in_every_format() { - let f = flag(); - for fmt in FORMATS { - let up = Upstream::start(vec![Answer::Text("好的".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("deepseek-flags", DEEPSEEK_FLAGS).models(&["deepseek*"])], - ) - .await; - ask( - &gw, - fmt, - "deepseek-chat", - &format!("系统 {f}"), - &poisoned_history(), - false, - ) - .await; - let raw = up.raw(0); - assert!( - !raw.contains(&f), - "{fmt:?}: the pair reached DeepSeek: {raw}" - ); - let sent = up.body(0); - assert!( - sent_system(&sent).contains(FLAG_PLACEHOLDER), - "{fmt:?}: {sent}" - ); - let texts = sent_texts(&sent); - // 用户的话、工具结果、助手的话:三处都换了 - assert_eq!( - texts.matches(FLAG_PLACEHOLDER).count(), - 3, - "{fmt:?}: {texts}" - ); - assert_eq!( - sent_tool_inputs(&sent)[0]["note"], - format!("找 {FLAG_PLACEHOLDER}"), - "{fmt:?}: {sent}" - ); - } -} - -#[tokio::test] -async fn deepseek_flags_restores_the_pair_in_text_and_tool_calls_in_every_format() { - let f = flag(); - for fmt in FORMATS { - // 文字:占位文字一个字一帧地到 - let up = Upstream::start(vec![Answer::Text(format!( - "页脚有 {FLAG_PLACEHOLDER},已记下" - ))]) - .await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("deepseek-flags", DEEPSEEK_FLAGS).models(&["deepseek*"])], - ) - .await; - let r = ask( - &gw, - fmt, - "deepseek-chat", - "你是助手。", - &[Turn::User("看看".into())], - true, - ) - .await; - assert_eq!( - fmt.text(&r.body, true), - format!("页脚有 {f},已记下"), - "{fmt:?}: {}", - r.body - ); - - // 工具调用:写出的文件里是原来的表情 - let up = Upstream::start(vec![Answer::Tool { - name: "Write".into(), - input: json!({ "file_path": "/tmp/a.html", "content": format!("

{FLAG_PLACEHOLDER}

") }), - }]) - .await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("deepseek-flags", DEEPSEEK_FLAGS).models(&["deepseek*"])], - ) - .await; - for stream in [true, false] { - let r = ask( - &gw, - fmt, - "deepseek-chat", - "你是助手。", - &[Turn::User("写文件".into())], - stream, - ) - .await; - let calls = fmt.calls(&r.body, stream); - assert_eq!(calls.len(), 1, "{fmt:?} stream={stream}: {}", r.body); - assert_eq!( - calls[0].1["content"], - format!("

{f}

"), - "{fmt:?} stream={stream}" - ); - } - } -} - -#[tokio::test] -async fn deepseek_flags_leaves_a_request_without_the_pair_byte_for_byte() { - let raw = format!( - r#"{{"model":"deepseek-chat", "max_tokens":256, "temperature":1.0, - "system":"你是助手。","messages":[{{"role":"user","content":"别的旗帜 {}"}}]}}"#, - // 别的国家的旗帜照常通过,不该被换 - [0x1F1EF_u32, 0x1F1F5] - .iter() - .map(|c| char::from_u32(*c).unwrap()) - .collect::() - ); - let up = Upstream::start(vec![Answer::Text("好的".into())]).await; - let gw = Gateway::start(config(&up, Security::default()), vec![]).await; - gw.post_raw("/v1/messages", &raw).await; - let baseline = up.raw(0); - - let up = Upstream::start(vec![Answer::Text("好的".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("deepseek-flags", DEEPSEEK_FLAGS).models(&["deepseek*"])], - ) - .await; - gw.post_raw("/v1/messages", &raw).await; - assert_eq!(up.raw(0), baseline); - assert_eq!(gw.outcomes_of("deepseek-flags", "request"), ["unchanged"]); -} - -#[tokio::test] -async fn deepseek_flags_is_deterministic_and_stays_in_its_scope() { - let f = flag(); - let up = Upstream::start(vec![Answer::Text("好的".into())]).await; - let gw = Gateway::start( - config(&up, Security::default()), - vec![Plug::new("deepseek-flags", DEEPSEEK_FLAGS).models(&["deepseek*"])], - ) - .await; - // 同样的请求两次:上游收到的字节一样,提示词缓存照常命中 - for _ in 0..2 { - ask( - &gw, - Fmt::Anthropic, - "deepseek-chat", - "你是助手。", - &poisoned_history(), - false, - ) - .await; - } - assert_eq!(up.raw(0), up.raw(1)); - assert!(!up.raw(0).contains(&f)); - - // 范围之外的模型:插件不跑,原样发出 - ask( - &gw, - Fmt::Anthropic, - MODEL, - "你是助手。", - &poisoned_history(), - false, - ) - .await; - assert!(up.raw(2).contains(&f), "{}", up.raw(2)); - // 两次在范围里的请求各跑一次请求钩子;范围外的那一次一个钩子都没跑 - assert_eq!( - gw.outcomes_of("deepseek-flags", "request"), - ["changed", "changed"] - ); - assert_eq!(gw.outcomes_of("deepseek-flags", "reply").len(), 2); -} - -/// 一个工具调用的参数里是不是还有占位文字(没换回去) -#[allow(dead_code)] -fn still_hidden(v: &Value) -> bool { - v.to_string().contains(FLAG_PLACEHOLDER) -} diff --git a/release-notes/0.58.0.md b/release-notes/0.58.0.md index ec0e8d6..7a4a15b 100644 --- a/release-notes/0.58.0.md +++ b/release-notes/0.58.0.md @@ -48,7 +48,7 @@ This release adds script plugins: JavaScript that changes requests before they g - Tool-call excerpts are masked before they reach the `tool_call_flagged` event, the security log and system notifications. A secret restored from a placeholder used to appear there in the clear. - Stored bodies no longer contain the secrets and personal numbers that the redaction rules recognize, whatever the redaction mode (see *Stored bodies*). - The retention settings now take effect. tw-store ran its own hourly cleanup with fixed limits (7 days of bodies, 90 days of rows, 2 GiB of bodies) beside the one that reads `retention`, so any setting above those limits was cut back every hour. That cleanup is removed. `retention.body_max_bytes` now defaults to 5 GiB (it was 2 GiB). - - On first start, core adds its three default plugins to `config.yaml`, turned off, and writes their files to `plugins/` beside it. That is one configuration version, recorded in the history as `defaults`. + - On first start, core adds its two default plugins to `config.yaml`, turned off, and writes their files to `plugins/` beside it. That is one configuration version, recorded in the history as `defaults`. **Session transcripts.** `GET /sessions/{id}/transcript` reads a session's stored bodies back as a conversation. For every turn it gives the messages that are new in that request, the answer, and what could not be shown. - Clients resend the whole history on every turn, so a turn shows only what its request added after the previous readable one. @@ -124,10 +124,9 @@ This release adds script plugins: JavaScript that changes requests before they g - turning on a plugin that can rewrite tool calls, or changing its settings or scope. `UpdatePlugin` refuses the second kind with 403 `control.plugin.needs_confirmation`. A client calls `CreatePlugin`, `ReplacePluginSource`, `ApprovePluginFile` and `UpdatePluginConfirmed` from native code after the user confirms, never from its web view. ThinkWatch Lite asks in a system dialog. -- **Default plugins.** Three ship with core and are added turned off: +- **Default plugins.** Two ship with core and are added turned off: - `reply-language` asks the model to answer in a chosen language. - `wsl-paths` converts drive paths in tool-call arguments between their WSL and Windows forms. - - `deepseek-flags` replaces a flag emoji that DeepSeek's API rejects, and restores it in answers. Its scope is `deepseek*`. A default that was deleted is not added back, and one that was changed is left alone. A new version that asks for more permissions or request kinds comes back turned off. The sandbox (about 7 MB of memory) starts only once a plugin is turned on. - **Limits.**