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
4 changes: 3 additions & 1 deletion crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ gw.output_limit.withheld
gw.plugin.answer_unreadable
gw.plugin.api
gw.plugin.bad_output
gw.plugin.cannot_read
gw.plugin.cannot_read_body
gw.plugin.changed
gw.plugin.cpu_limit
gw.plugin.engine
Expand All @@ -291,6 +291,8 @@ gw.plugin.file_changed
gw.plugin.manifest
gw.plugin.memory_limit
gw.plugin.model_not_allowed
gw.plugin.not_applicable
gw.plugin.not_declared
gw.plugin.not_located
gw.plugin.nothing_to_try
gw.plugin.output_limit
Expand Down
30 changes: 30 additions & 0 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,12 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// (`UpdatePluginConfirmed`,请求体同 [`PluginUpdate`])—— 它和装、换源码、批准一样
/// 不给网页调,桌面端在系统的确认框里点了头才发。同一版起 core 自带几个默认插件,第一次
/// 见到时装上、停用着,写配置的这一版来源是 [`ConfigOrigin::Defaults`]。
///
/// 33 起**插件说得出自己处理哪几种请求**:[`ManifestView`] 和 [`PluginView`] 多了
/// `requests`([`RequestKind`]:对话、嵌入、旧版补全)。插件只处理声明了的那几种 ——
/// 不写是只有对话;嵌入和旧版补全要插件自己声明 —— 别的种类的请求不过它、不记录,
/// 它出错、文件变了也拦不着它们。嵌入和旧版补全的视图是一项输入一条消息,`ctx.format`
/// 多了 `openai_embeddings`、`openai_completions`、`gemini_embed`。
pub const CONTROL_API_VERSION: u32 = 33;

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -4870,6 +4876,23 @@ slug_enum! {
}
}

slug_enum! {
/// 一种请求。插件**只处理它声明了的那几种**(插件文件里 manifest 的 `requests`,
/// 不写就是只有 `conversation`):别的种类的请求原样过去,不记录,插件出了什么错也
/// 和它们无关。图片、音频这些别的接口不属于任何一种,所有插件都不管。
pub enum RequestKind {
/// 对话:Anthropic Messages、OpenAI Chat Completions、Responses、Gemini 的生成,
/// 连同它们的数 token 和压缩
Conversation = "conversation",
/// 嵌入:OpenAI 的 `/v1/embeddings`,Gemini 的 `:embedContent`、
/// `:batchEmbedContents`。插件只改得了每项输入的文字,回答钩子不在它上面跑
Embeddings = "embeddings",
/// 旧版补全:OpenAI 的 `/v1/completions`。插件只改得了每段提示的文字和几个参数,
/// 回答钩子不在它上面跑
Completions = "completions",
}
}

slug_enum! {
/// 插件出错(运行出错、文件变了、加载不了)时这个请求怎么办。
pub enum OnError {
Expand Down Expand Up @@ -5039,6 +5062,9 @@ pub struct ManifestView {
pub name: String,
pub description: Option<String>,
pub permissions: Vec<Permission>,
/// 插件处理哪几种请求,按 [`RequestKind::ALL`] 的顺序。至少有一种;manifest 没写
/// `requests` 时是 `["conversation"]`
pub requests: Vec<RequestKind>,
/// 插件建议的范围。装上时照它填
pub scope: PluginScope,
pub reply_mode: ReplyMode,
Expand Down Expand Up @@ -5075,6 +5101,9 @@ pub struct PluginView {
pub on_error: OnError,
/// 读不出 manifest 时是空的
pub permissions: Vec<Permission>,
/// 插件处理哪几种请求,按 [`RequestKind::ALL`] 的顺序(见 [`ManifestView::requests`])。
/// 读不出 manifest 时按出厂的算:`["conversation"]` —— 跑不了的插件拦的也就是这几种
pub requests: Vec<RequestKind>,
/// 生效的范围(配置里的)
pub scope: PluginScope,
pub reply_mode: ReplyMode,
Expand Down Expand Up @@ -5360,6 +5389,7 @@ mod tests {
check(Guard::ALL, Guard::slug, Guard::from_slug);
check(RuleAction::ALL, RuleAction::slug, RuleAction::from_slug);
check(Permission::ALL, Permission::slug, Permission::from_slug);
check(RequestKind::ALL, RequestKind::slug, RequestKind::from_slug);
check(OnError::ALL, OnError::slug, OnError::from_slug);
check(ReplyMode::ALL, ReplyMode::slug, ReplyMode::from_slug);
check(SettingKind::ALL, SettingKind::slug, SettingKind::from_slug);
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-control/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fn view(a: &Active, entry: &tw_config::Plugin) -> tw_api::PluginView {
enabled: a.enabled,
on_error: a.on_error,
permissions: a.permissions.clone(),
requests: a.requests.clone(),
scope: scope_view(&entry.scope),
reply_mode: a.reply_mode,
settings_schema: m.map(schema).unwrap_or_default(),
Expand Down Expand Up @@ -171,6 +172,7 @@ fn manifest_view(m: &Manifest) -> tw_api::ManifestView {
name: m.name.clone(),
description: m.description.clone(),
permissions: m.permissions.clone(),
requests: m.requests.clone(),
scope: tw_api::PluginScope {
clients: m.scope.clients.clone(),
models: m.scope.models.clone(),
Expand Down Expand Up @@ -1050,6 +1052,7 @@ async fn awaken(s: &ControlState, a: &Active) -> Result<Active, Msg> {
on_error: a.on_error,
scope: a.scope.clone(),
permissions: m.permissions.clone(),
requests: m.requests.clone(),
reply_mode: m.reply_mode,
hooks: m.hooks,
settings,
Expand Down
10 changes: 7 additions & 3 deletions crates/tw-control/src/plugins/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
//! 哈希是发出去的那份字节的 —— 再记下来;
//! - **给过、配置里还在、文件和批准的都还是给出去的那一份,而 core 带的已经是新版**:
//! 换文件、底稿和配置里的哈希;开关、出错时怎么办、范围和还声明着的设置照旧,新声明的
//! 设置取默认值;**新版要了旧版没要的权限就停用**;记下新版;
//! 设置取默认值;**新版要了旧版没要的权限、或者多处理了一种请求(`requests`),就停用**;
//! 记下新版;
//! - **给过、配置里没有了**:用户删的。**不再加回去**;
//! - **给过、文件被用户改过**(或者批准的已经是别的一份):不动。
//!
Expand Down Expand Up @@ -84,7 +85,8 @@ pub struct Seeded {
pub added: Vec<String>,
/// 换成了新版的
pub updated: Vec<String>,
/// 换成新版时停用了的:开着,而新版要了旧版没要的权限。也在 `updated` 里
/// 换成新版时停用了的:开着,而新版要了旧版没要的权限、或者多处理了一种请求。也在
/// `updated` 里
pub disabled: Vec<String>,
/// 只记了一笔「给过了」的:用户自己的插件占着这个 id,或者新版已经装上了
pub marked: Vec<String>,
Expand Down Expand Up @@ -259,14 +261,16 @@ impl Seeder {
continue;
}
};
// 旧版要过哪些权限。读不出来就当新版多要了 —— 宁可停用
// 旧版要过哪些权限、处理哪几种请求。读不出来就当新版多要了 —— 宁可停用。
// 多处理一种请求和多要一个权限一样:插件看得到、改得了的东西变多了
let more = if p.enabled {
let old = match bytes {
Some(b) => compile(mgr, &b, false).await.ok(),
None => None,
};
old.as_ref().is_none_or(|old| {
new.permissions.iter().any(|x| !old.permissions.contains(x))
|| new.requests.iter().any(|k| !old.requests.contains(k))
})
} else {
false
Expand Down
30 changes: 30 additions & 0 deletions crates/tw-control/tests/plugin_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,36 @@ async fn a_new_version_that_wants_more_permissions_comes_back_turned_off() {
assert_eq!(v["permissions"], json!(["system", "messages"]));
}

/// 新版多处理了一种请求(`requests` 多了嵌入),权限一样:和多要一个权限一样,换上但
/// 停用 —— 插件看得到、改得了的东西变多了,要用户自己再打开
#[tokio::test]
async fn a_new_version_that_handles_more_kinds_of_request_comes_back_turned_off() {
let b = bed();
let scrub = |requests: Value| {
source(
json!({"name": "Scrub", "api": 1, "permissions": ["messages"],
"requests": requests}),
&["onRequest"],
)
};
let v1 = scrub(json!(["conversation"]));
seeder(&[("scrub", &v1)]).seed(&b.mgr).await;
b.customize("scrub", json!({})).await;
assert_eq!(b.plugin("scrub").await["requests"], json!(["conversation"]));

let v2 = scrub(json!(["conversation", "embeddings"]));
let done = seeder(&[("scrub", &v2)]).seed(&b.mgr).await;
assert_eq!(ids(&done.updated), ["scrub"], "{done:?}");
assert_eq!(ids(&done.disabled), ["scrub"], "{done:?}");
let p = b.entry("scrub").unwrap();
assert_eq!(p.sha256, sha(&v2));
assert!(!p.enabled);
let v = b.plugin("scrub").await;
assert_eq!(v["status"], json!({"kind": "disabled"}));
assert_eq!(v["permissions"], json!(["messages"]));
assert_eq!(v["requests"], json!(["conversation", "embeddings"]));
}

/// 用户自己的插件正好用了一个默认插件的 id:只记一笔「给过了」,它的文件和配置都不动,
/// 之后出了新版也不动
#[tokio::test]
Expand Down
108 changes: 108 additions & 0 deletions crates/tw-control/tests/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1478,3 +1478,111 @@ export function onRequest(req, ctx) {
assert_eq!(v["status"], json!({"kind": "ok"}));
assert_eq!(v["settings_schema"][0]["default"], "tomorrow");
}

/// 试跑记下的嵌入请求,从控制面一路到真的沙箱:声明了嵌入的插件跑在一项输入一条消息的
/// 视图上,前后两份打着码,回答钩子不试;`inspect` 和列表说得出它处理哪几种请求。只处理
/// 对话的插件说清它当时没跑
#[tokio::test]
async fn a_trial_on_a_recorded_embeddings_request_runs_only_plugins_that_declare_embeddings() {
let b = bed_with("real", false);
let scrub = r#"export const manifest = {
name: "Scrub inputs",
api: 1,
permissions: ["messages"],
requests: ["conversation", "embeddings"],
};
export function onRequest(req, ctx) {
console.log(ctx.format);
for (const m of req.messages) {
for (const p of m.parts) {
if (p.type === "text") p.text = p.text.replaceAll("PROJECT-X", "[removed]");
}
}
return req;
}
"#;
let (_, v) = call(
&b.app,
"POST",
"/plugins/inspect",
Some(json!({"source": scrub})),
)
.await;
assert_eq!(
v["manifest"]["requests"],
json!(["conversation", "embeddings"]),
"{v}"
);
let id = b.install(scrub, json!({})).await;
assert_eq!(
b.plugin(&id).await["requests"],
json!(["conversation", "embeddings"])
);
let key = "sk-ant-api03-TRIALKEYAAAAAAAAAAAAAAAAAAAA";
let request = json!({
"model": "text-embedding-3-small",
"input": ["PROJECT-X roadmap", format!("key {key}"), [101, 102]]
})
.to_string();
let answer =
json!({"object": "list", "data": [], "model": "text-embedding-3-small"}).to_string();
let mut embeddings = row(9, 1_000);
embeddings.path = "/v1/embeddings".into();
embeddings.provider = "openai".into();
embeddings.model = "text-embedding-3-small".into();
embeddings.sent_model = "text-embedding-3-small".into();
{
let g = b.store.lock().await;
g.db().insert(&embeddings).unwrap();
g.record_body(
1_000,
9,
tw_store::Which::Request,
request.as_bytes(),
request.len(),
);
g.record_body(
1_000,
9,
tw_store::Which::Response,
answer.as_bytes(),
answer.len(),
);
}
let (st, v) = call(
&b.app,
"POST",
&format!("/plugins/{id}/trial"),
Some(json!({"request_id": 9})),
)
.await;
assert_eq!(st, StatusCode::OK, "{v}");
assert!(v["error"].is_null(), "{v}");
assert!(v["reply"].is_null(), "{v}");
assert_eq!(v["request"]["outcome"], "changed", "{v}");
let after: Value = serde_json::from_str(v["request"]["after"].as_str().unwrap()).unwrap();
assert_eq!(after["input"][0], "[removed] roadmap");
assert_eq!(after["input"][2], json!([101, 102]));
assert!(
!v.to_string().contains("TRIALKEY"),
"a secret was shown: {v}"
);
assert_eq!(v["logs"][0]["text"], "openai_embeddings", "{v}");

// 只处理对话的插件:当时它就不在这个请求的范围里
let chat_only = r#"export const manifest = { name: "Chat only", api: 1, permissions: ["messages"] };
export function onRequest(req) { return req; }
"#;
let other = b.install(chat_only, json!({})).await;
assert_eq!(b.plugin(&other).await["requests"], json!(["conversation"]));
let (st, v) = call(
&b.app,
"POST",
&format!("/plugins/{other}/trial"),
Some(json!({"request_id": 9})),
)
.await;
assert_eq!(st, StatusCode::OK, "{v}");
assert!(v["request"].is_null(), "{v}");
assert_eq!(v["error"]["code"], "gw.plugin.not_declared", "{v}");
}
49 changes: 49 additions & 0 deletions crates/tw-gateway/src/client_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,26 @@ impl ClientApi {
|| p == "/backend-api/codex/responses/compact"
}

/// 这个路径是不是嵌入:OpenAI 的 `/v1/embeddings`,Gemini 的 `:embedContent`、
/// `:batchEmbedContents`。
///
/// 插件声明了 `embeddings` 才处理它们(见 [`crate::plugin::request::Shape`])
pub fn embeds(path: &str) -> bool {
let p = path.trim_end_matches('/');
p.strip_prefix("/v1").unwrap_or(p) == "/embeddings"
|| (p.contains("/models/")
&& (p.ends_with(":embedContent") || p.ends_with(":batchEmbedContents")))
}

/// 这个路径是不是 OpenAI 的旧版补全(`/v1/completions`)。Anthropic 的旧版补全
/// (`/v1/complete`)不算:插件不管它。
///
/// 插件声明了 `completions` 才处理它(见 [`crate::plugin::request::Shape`])
pub fn completes(path: &str) -> bool {
let p = path.trim_end_matches('/');
p.strip_prefix("/v1").unwrap_or(p) == "/completions"
}

/// 转换库里对应的格式
pub fn dialect(&self) -> Dialect {
match self {
Expand Down Expand Up @@ -288,6 +308,35 @@ mod tests {
}
}

/// 嵌入、旧版补全各是哪几个路径:生成回答、数 token、别家的旧版补全都不算
#[test]
fn embeddings_and_legacy_completions_are_told_apart_by_path() {
for (path, embeds, completes) in [
("/v1/embeddings", true, false),
("/embeddings/", true, false),
(
"/v1beta/models/gemini-embedding-001:embedContent",
true,
false,
),
(
"/v1beta/models/text-embedding-004:batchEmbedContents",
true,
false,
),
("/v1/completions", false, true),
("/completions", false, true),
("/v1/chat/completions", false, false),
("/v1/complete", false, false),
("/v1/messages", false, false),
("/v1beta/models/gemini-2.5-pro:countTokens", false, false),
("/v1/images/generations", false, false),
] {
assert_eq!(ClientApi::embeds(path), embeds, "{path}");
assert_eq!(ClientApi::completes(path), completes, "{path}");
}
}

#[test]
fn a_path_we_do_not_know_is_not_guessed() {
for path in ["/v1/models", "/v1/files", "/healthz", "/v1/messagesx", "/"] {
Expand Down
9 changes: 9 additions & 0 deletions crates/tw-gateway/src/plugin/defaults/manifests.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"reply_tool_calls"
],
"reply_mode": "stream",
"requests": [
"conversation"
],
"scope": {
"clients": [],
"models": [
Expand All @@ -43,6 +46,9 @@
"system"
],
"reply_mode": "block",
"requests": [
"conversation"
],
"scope": {
"clients": [],
"models": [],
Expand Down Expand Up @@ -75,6 +81,9 @@
"reply_tool_calls"
],
"reply_mode": "block",
"requests": [
"conversation"
],
"scope": {
"clients": [],
"models": [],
Expand Down
Loading
Loading