Skip to content
Open
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
55 changes: 54 additions & 1 deletion desktop-tauri/src-tauri/server/src/server/api/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ pub async fn chat_completions(
Some(context) => context.stream(source),
None => source,
};
// 内部暂存字段(`_wb_`)是网关自己的连续性载荷,Responses /
// Anthropic 出口会消费;Chat 客户端没有这条链路,不能看到它们。
let source = scrub_internal_chat_frames(source);
let status = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
let context = RecordContext {
stats,
Expand Down Expand Up @@ -279,6 +282,8 @@ pub async fn chat_completions(
Some(context) => context.complete(body).await,
None => body,
};
let body =
crate::server::core::protocol::strip_internal_chat_fields(&body).into_owned();
// 响应正文在记账前抄一份(完整 JSON 文本;序列化失败或调试模式
// 关着给 None,那一侧少存一份正文不影响明细)
let raw_response = pipeline::capture_raw_json(&body);
Expand Down Expand Up @@ -492,4 +497,52 @@ pub fn spawn_catalog_refresh(state: &ServerState) {
crate::spawn_task(async move {
crate::server::core::providers::adapter::refresh_implemented(&store).await;
});
}
}

/// Chat 出口的 SSE 清洗:剥掉内部暂存字段(`_wb_` 前缀)后再下发。
///
/// 只在帧里真的出现 `_wb_` 时才重新序列化(绝大多数帧原样返回),因此
/// 热路径上没有额外解析成本。为什么会出现在这里:上游是 Responses 协议时,
/// 内部的 chat 帧承载 `_wb_reasoning_item_id` / `_wb_encrypted_content`,
/// Responses 与 Anthropic 出口要读它们,Chat 客户端不该看到。
fn scrub_internal_chat_frames(
source: Box<dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin>,
) -> Box<dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin> {
use futures::StreamExt;
Box::new(source.flat_map(|item| {
let frames: Vec<Result<Bytes, std::io::Error>> = match item {
Ok(chunk) => scrub_internal_chat_chunk(&chunk).into_iter().map(Ok).collect(),
Err(error) => vec![Err(error)],
};
futures::stream::iter(frames)
}))
}

/// 一帧字节的清洗本体(一帧可能含多行 `data:`)。
fn scrub_internal_chat_chunk(chunk: &Bytes) -> Vec<Bytes> {
let Ok(text) = std::str::from_utf8(chunk) else {
return vec![chunk.clone()];
};
if !text.contains("_wb_") {
return vec![chunk.clone()];
}
let mut out = String::with_capacity(chunk.len());
for line in text.split_inclusive('\n') {
let Some(payload) = line.strip_prefix("data: ") else {
out.push_str(line);
continue;
};
let payload = payload.trim_end_matches('\n').trim_end_matches('\r');
if let Ok(value) = serde_json::from_str::<Value>(payload) {
let stripped = crate::server::core::protocol::strip_internal_chat_fields(&value);
if let Ok(clean) = serde_json::to_string(&stripped) {
out.push_str("data: ");
out.push_str(&clean);
out.push('\n');
continue;
}
}
out.push_str(line);
}
vec![Bytes::from(out)]
}
86 changes: 86 additions & 0 deletions desktop-tauri/src-tauri/server/src/server/core/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ pub const FIELD_CACHE_CONTROL: &str = "_wb_cache_control";
pub const FIELD_IS_ERROR: &str = "_wb_is_error";
/// reasoning encrypted_content 的暂存字段(assistant 消息上,值 = 原样的加密串)
pub const FIELD_ENCRYPTED_CONTENT: &str = "_wb_encrypted_content";
/// reasoning item id 的暂存字段(与 encrypted_content 同一消息,保持 store=false
/// 连续性载荷的身份对应关系)
pub const FIELD_REASONING_ITEM_ID: &str = "_wb_reasoning_item_id";

/// 内部暂存字段的前缀:剥离按**前缀**而不是逐个常量 —— 将来加新字段时
/// 剥离层自动覆盖,不会因为忘了同步清单而把内部字段漏给上游。
Expand Down Expand Up @@ -376,9 +379,92 @@ fn has_internal_fields(value: &Value) -> bool {
.is_some_and(|object| object.keys().any(|key| key.starts_with(INTERNAL_FIELD_PREFIX)))
}

/// 剥离**发给 Chat 客户端**的响应体上的内部暂存字段。
///
/// 与 [`strip_internal_fields`] 的分工:那个处理**请求**体(`messages` /
/// `tools`),这个处理**响应**体(顶层 + `choices[].delta` /
/// `choices[].message`)。
///
/// 为什么 Chat 出口要剥而内部不剥:`_wb_` 字段是网关内部的连续性载荷,
/// Responses / Anthropic 出口的转换器要读它们(见 `responses_outbound` 与
/// `responses::PendingReasoning`);而 Chat 客户端没有这条消费链,留着只是把
/// 内部字段(以及体积可观的 `encrypted_content`)漏给调用方。
///
/// 没有任何内部字段时返回 `Cow::Borrowed`(零拷贝:绝大多数响应的形态)。
pub fn strip_internal_chat_fields(body: &Value) -> Cow<'_, Value> {
let Some(object) = body.as_object() else {
return Cow::Borrowed(body);
};
let top_dirty = object.keys().any(|key| key.starts_with(INTERNAL_FIELD_PREFIX));
let choices_dirty = object
.get("choices")
.and_then(Value::as_array)
.is_some_and(|choices| choices.iter().any(chat_choice_dirty));
if !top_dirty && !choices_dirty {
return Cow::Borrowed(body);
}
let mut next = object.clone();
next.retain(|key, _| !key.starts_with(INTERNAL_FIELD_PREFIX));
if let Some(choices) = next.get_mut("choices").and_then(Value::as_array_mut) {
for choice in choices.iter_mut() {
if let Some(choice) = choice.as_object_mut() {
for slot in ["delta", "message"] {
if let Some(payload) = choice.get_mut(slot) {
strip_on_object(payload);
}
}
}
}
}
Cow::Owned(Value::Object(next))
}

/// chat 的一个 choice 上,`delta` / `message` 是否带内部暂存字段
fn chat_choice_dirty(choice: &Value) -> bool {
["delta", "message"].iter().any(|slot| {
choice
.get(*slot)
.is_some_and(has_internal_fields)
})
}

/// 原地剥掉一个对象上的内部暂存字段(对象本身存在才调用)
fn strip_on_object(value: &mut Value) {
if let Some(object) = value.as_object_mut() {
object.retain(|key, _| !key.starts_with(INTERNAL_FIELD_PREFIX));
}
}

#[cfg(test)]
mod internal_fields_tests {
use super::*;
use serde_json::json;

/// Chat 出口剥离:内部字段只在 `delta` / `message` 上出现时也要清掉,
/// 其余内容原样保留;没有内部字段时走零拷贝分支。
#[test]
fn chat_egress_strips_internal_fields() {
let body = json!({
"id": "c",
"_wb_top": 1,
"choices": [{
"index": 0,
"delta": {
"role": "assistant",
"content": "",
"_wb_reasoning_item_id": "rs_1",
"_wb_encrypted_content": "blob",
},
}],
});
let clean = strip_internal_chat_fields(&body);
assert!(clean.get("_wb_top").is_none());
let delta = &clean["choices"][0]["delta"];
assert!(delta.get("_wb_reasoning_item_id").is_none());
assert!(delta.get("_wb_encrypted_content").is_none());
assert_eq!(delta["role"], "assistant");

let plain = json!({ "choices": [{ "delta": { "content": "hi" } }] });
assert!(matches!(strip_internal_chat_fields(&plain), Cow::Borrowed(_)));
}
}
Loading