diff --git a/.env.example b/.env.example
index a3243d6c..dfe0992e 100644
--- a/.env.example
+++ b/.env.example
@@ -638,3 +638,13 @@ ALLOW_PRIVATE_PROXY_HOSTS=
# WINDSURFAPI_NO_OPEN=1
# gRPC 传输协议;'connect' 走 Connect 协议,默认走 gRPC。仅影响 Cascade 路径。
# GRPC_PROTOCOL=connect
+
+# --- Reasoning/content 边界泄漏追踪 (实验性,默认 OFF) ---
+# WINDSURFAPI_LEAK_TRACE=1
+# 输出 LEAK_TRACE 前缀的结构化日志,用于在线抓取"模型推理(thinking)泄漏进
+# content 通道"的活体问题(#238/#241/#243 只做了 rescue 与 ` thinking` 重排,
+# 从未在强制复现中抓到泄漏本身)。每个流事件记:channel(content/reasoning)、
+# think(命中的标记,如 / ◁think▷)、sample(截断样本,非全文)、
+# len、reqId/account;messages 路径记 block-start/classify(blockType/channel/
+# msgId/reqId);settle 时记 contentChars/reasoningChars/rerouted。默认 OFF:
+# 关闭时热路径只读一次 env 标志,不改任何行为。
diff --git a/README.md b/README.md
index 1e540459..c2eda7c5 100644
--- a/README.md
+++ b/README.md
@@ -275,6 +275,7 @@ curl http://localhost:3003/v1/messages \
| `DEFAULT_MODEL` | `claude-sonnet-4.6` | 不传 model 用哪个。必须是当前后端能解析的名字 —— connect 上解析不到的名字会静默降级成免费 selector |
| `MAX_TOKENS` | `8192` | 默认最大回复 token 数 |
| `LOG_LEVEL` | `info` | debug / info / warn / error |
+| `WINDSURFAPI_LEAK_TRACE` | off | 推理/内容边界结构化日志(实验性,默认关闭)。开启后输出 `LEAK_TRACE` 前缀日志:原始流事件所属通道(content/reasoning)、think 标记、截断文本样本、settle 时 content/reasoning 字符数。用于在线抓取模型推理泄漏进 content 通道的问题。字段:channel/blockType/think/sample/len/reqId/account/msgId/contentChars/reasoningChars/rerouted |
| `WINDSURFAPI_IGNORE_CLOUD_FILTER` | `0` | Cascade 路径下,各账号云端 catalog 同步后,账号池列表展示活跃账号目录的并集,路由则校验所选账号自己的目录;设为 `1` 恢复完整静态 catalog。目录缺失、为空或同步失败时保持 fail-open;`DEVIN_CONNECT` 使用独立 selector catalog |
| `LS_BINARY_PATH` | `/opt/windsurf/language_server_linux_x64` | LS 二进制位置 |
| `LS_DATA_DIR` | Linux: `/opt/windsurf/data`;macOS: `~/.windsurf/data` | 每个 proxy 独立的 LS 数据根目录 |
diff --git a/src/devin-connect-openai.js b/src/devin-connect-openai.js
index 3ea0e56c..f911f772 100644
--- a/src/devin-connect-openai.js
+++ b/src/devin-connect-openai.js
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
import { streamChat as realStreamChat, isRetryable, messageText } from './devin-connect.js';
import { ToolCallStreamParser, parseToolCallsFromText, isWeakEmulationModel } from './handlers/tool-emulation.js';
import { log } from './config.js';
+import { leakTraceEnabled, thinkMarkersIn, leakSample } from './leak-trace.js';
import { systemFingerprint } from './system-fingerprint.js';
import { applyStop, StopSequenceGate } from './stop-sequences.js';
import { normalizeToolCallArgs, recordArgRepair } from './handlers/cline-compat.js';
@@ -124,7 +125,7 @@ function isEmptyCompletion(finishEv, sawContent) {
// guard aimed at it guards code that cannot be reached (the exact shape of the v3.9.13 medium
// defect). If a caller ever genuinely needs to opt out, add the option back THEN, with a test
// that passes `false` — that test is what makes it a knob rather than decoration.
-async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) {
+async function* streamChatWithEmptyRetry(params, { env = process.env } = {}, opts = {}) {
// Weak models (fable) return DETERMINISTIC empties on complex multi-turn / large
// system — paid E2E (2026-07-08, 27/27) proved retry never heals them, it only
// triples the upstream load and burns the account into a 3h rate limit. So for
@@ -223,6 +224,16 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) {
sawReasoningText += ev.text;
}
}
+ if (leakTraceEnabled(env)) {
+ log.info('LEAK_TRACE stream-event', {
+ channel: ev.type,
+ think: thinkMarkersIn(ev.text),
+ sample: leakSample(ev.text),
+ len: ev.text ? ev.text.length : 0,
+ reqId: opts?.reqId ?? null,
+ account: opts?.account ?? null,
+ });
+ }
yield ev;
} else if (ev.type === 'finish') {
finishEv = ev; // hold: decide retry after the stream drains
@@ -322,7 +333,8 @@ function nowSeconds() {
* tool_calls (text-emulation, swe-1.6 etc).
* @returns {Promise<{status:number, body:object}>}
*/
-export async function toChatCompletion(params, { id = newId(), created = nowSeconds(), displayModel, maxRetries = 2, retryBaseMs = 400, emulateTools = false, stop = null, clineCompat = false } = {}) {
+export async function toChatCompletion(params, opts = {}) {
+ const { id = newId(), created = nowSeconds(), displayModel, maxRetries = 2, retryBaseMs = 400, emulateTools = false, stop = null, clineCompat = false } = opts;
const model = displayModel || params.model;
// Non-stream path buffers the whole answer, so a transient failure (network
@@ -345,7 +357,7 @@ export async function toChatCompletion(params, { id = newId(), created = nowSeco
for (let attempt = 0; ; attempt++) {
try {
content = ''; reasoning = ''; finishReason = 'stop'; usage = null; nativeToolCalls = [];
- for await (const ev of streamChatWithEmptyRetry(params)) {
+ for await (const ev of streamChatWithEmptyRetry(params, undefined, opts)) {
// A rescue attempt REPLACES the previous one; drop what it produced or the
// client is handed every attempt concatenated.
if (ev.type === 'attempt_reset') { content = ''; reasoning = ''; nativeToolCalls = []; billing = null; continue; }
@@ -494,7 +506,8 @@ export async function toChatCompletion(params, { id = newId(), created = nowSeco
* @returns {Promise<{content:string, reasoning:string, finish_reason:string, usage:object|null}>}
* the assembled result, so callers can cache it after streaming.
*/
-export async function streamChatCompletion(params, send, { id = newId(), created = nowSeconds(), displayModel, emulateTools = false, includeUsage = false, stop = null, clineCompat = false } = {}) {
+export async function streamChatCompletion(params, send, opts = {}) {
+ const { id = newId(), created = nowSeconds(), displayModel, emulateTools = false, includeUsage = false, stop = null, clineCompat = false } = opts;
const model = displayModel || params.model;
const base = { id, object: OBJECT_CHUNK, created, model, system_fingerprint: systemFingerprint(model) };
@@ -570,7 +583,7 @@ export async function streamChatCompletion(params, send, { id = newId(), created
}
};
- for await (const ev of streamChatWithEmptyRetry(params)) {
+ for await (const ev of streamChatWithEmptyRetry(params, undefined, opts)) {
// A rescue attempt REPLACES the previous one. The deltas already sent cannot be
// retracted (documented at the promotion site below), but the accumulators must
// not keep the abandoned attempt — otherwise `content` ends up holding every
diff --git a/src/handlers/chat.js b/src/handlers/chat.js
index d02707ad..2b7e1096 100644
--- a/src/handlers/chat.js
+++ b/src/handlers/chat.js
@@ -10,6 +10,7 @@ import { isStickyEnabled, setStickyBinding, peekStickyBinding } from '../account
import { resolveModel, getModelInfo, pickRateLimitFallback } from '../models.js';
import { getLsFor, ensureLs } from '../langserver.js';
import { config, log } from '../config.js';
+import { leakTraceEnabled, thinkMarkersIn, leakSample } from '../leak-trace.js';
import { safeAccountRef, safeKeyRef, safeLogValue } from '../log-safety.js';
import { recordRequest, recordTokenUsage, recordPolicyBlocked, recordRateLimited } from '../dashboard/stats.js';
import { extractIntentFromNarrative, detectToolIntentInNarrative } from './intent-extractor.js';
@@ -3131,7 +3132,7 @@ async function _handleChatCompletionsInner(body, context = {}) {
const connectDisplayModel = mapped ? reqModelName : connectParams.model;
// O1: honor stream_options.include_usage on the connect path too — the
// trailing usage frame is emitted only when the caller opted in.
- const connectMeta = { id: ccId, created: ccCreated, displayModel: connectDisplayModel, emulateTools, includeUsage: body.stream_options?.include_usage === true, stop: body.stop ?? null, clineCompat: clineCompatActive };
+ const connectMeta = { id: ccId, created: ccCreated, displayModel: connectDisplayModel, emulateTools, includeUsage: body.stream_options?.include_usage === true, stop: body.stop ?? null, clineCompat: clineCompatActive, reqId, account: ccAcct ? safeAccountRef(ccAcct) : null };
// Shared failover bookkeeping for both stream + non-stream paths. triedKeys
// accumulates every session token burned this request so getApiKey never
// re-picks a known-dead account when we hop to the next pool member.
@@ -3325,6 +3326,7 @@ async function _handleChatCompletionsInner(body, context = {}) {
};
try {
let acct = ccAcct;
+ let lastOkSr = null;
for (let hops = 0; ; hops++) {
// Client gone (disconnect or shutdown abort): stop before touching
// another pooled account. Without this the failover loop keeps
@@ -3353,6 +3355,7 @@ async function _handleChatCompletionsInner(body, context = {}) {
if (acct) triedKeys.push(acct.apiKey);
const r = await attemptStream(acct);
if (r.kind === 'ok') {
+ lastOkSr = r.sr;
// Pin this conversation to the account that just served it, so the
// next turn reads the prompt cache back instead of re-writing it.
// Complementary to the pair-chain commit below: that keeps the
@@ -3418,6 +3421,19 @@ async function _handleChatCompletionsInner(body, context = {}) {
bumpConnect('failover_hops');
acct = next;
}
+ if (leakTraceEnabled()) {
+ log.info('LEAK_TRACE settle', {
+ reqId,
+ model: connectDisplayModel,
+ provider: null,
+ contentChars: (lastOkSr?.content ?? '').length,
+ reasoningChars: (lastOkSr?.reasoning ?? '').length,
+ rerouted: false,
+ thinkInContent: thinkMarkersIn(lastOkSr?.content),
+ sample: leakSample(lastOkSr?.content),
+ durationMs: Date.now() - ccStart,
+ });
+ }
// If the client already disconnected, don't try to write to a dead
// socket — that can leave the handler stuck on a closed connection.
if (!abortController.signal.aborted && !res.writableEnded) { res.write('data: [DONE]\n\n'); res.end(); }
@@ -5846,19 +5862,34 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad
// `modelKey` param (caller passes routingModelKey there);
// wantThinking comes through deps because body isn't in
// scope here (#93 follow-up zhangzhang-bit).
- if (shouldFallbackThinkingToText({
+ const thinkingPromotedToText = shouldFallbackThinkingToText({
routingModelKey: modelKey,
wantThinking: deps.wantThinking,
accText,
accThinking,
hasToolCalls: collectedToolCalls.length > 0,
- })) {
+ });
+ if (thinkingPromotedToText) {
log.info(`Chat[${reqId}]: thinking-only stream from non-reasoning model ${modelKey}; promoting ${accThinking.length}c thinking → content`);
send({ id, object: 'chat.completion.chunk', created, model,
choices: [{ index: 0, delta: { content: accThinking }, finish_reason: null }] });
accText = accThinking;
accThinking = '';
}
+ if (leakTraceEnabled()) {
+ log.info('LEAK_TRACE settle', {
+ reqId,
+ model,
+ modelKey,
+ provider,
+ contentChars: accText.length,
+ reasoningChars: accThinking.length,
+ rerouted: thinkingPromotedToText,
+ thinkInContent: thinkMarkersIn(accText),
+ sample: leakSample(accText),
+ durationMs: Date.now() - startTime,
+ });
+ }
const finalReason = collectedToolCalls.length ? 'tool_calls' : 'stop';
// OpenAI spec: the finish_reason chunk carries NO usage, then a
// separate terminal chunk has empty choices[] + usage
diff --git a/src/handlers/messages.js b/src/handlers/messages.js
index c08ad42a..f7c5f3b1 100644
--- a/src/handlers/messages.js
+++ b/src/handlers/messages.js
@@ -14,6 +14,7 @@
import { createHash, randomUUID } from 'crypto';
import { handleChatCompletions, connectErrorToHttp } from './chat.js';
import { log } from '../config.js';
+import { leakTraceEnabled, thinkMarkersIn, leakSample } from '../leak-trace.js';
function genMsgId() {
return 'msg_' + randomUUID().replace(/-/g, '').slice(0, 24);
@@ -940,10 +941,14 @@ function buildAnthropicUsage(usage, cachePolicy = null) {
// ─── Streaming translator: intercepts OpenAI SSE, emits Anthropic SSE ──
class AnthropicStreamTranslator {
- constructor(res, msgId, model, cachePolicy = null, inputEstimate = 0, stopSequences = null) {
+ constructor(res, msgId, model, cachePolicy = null, inputEstimate = 0, stopSequences = null, reqId = null, conversationId = null) {
this.res = res;
this.msgId = msgId;
this.model = model;
+ // Stage E leak-trace identifiers (WINDSURFAPI_LEAK_TRACE): null unless the
+ // caller threads them in — log fields only, no behavior.
+ this.reqId = reqId;
+ this.conversationId = conversationId;
// Local cache-prefix estimate from extractCachePolicy; used by finish() to
// fill cache_creation_input_tokens when upstream reports none (see
// buildAnthropicUsage). null when the request had no cache_control markers.
@@ -1052,6 +1057,16 @@ class AnthropicStreamTranslator {
index: this.blockIndex,
content_block,
});
+ if (leakTraceEnabled()) {
+ log.info('LEAK_TRACE block-start', {
+ blockType: type,
+ channel: type === 'thinking' ? 'reasoning' : 'content',
+ msgId: this.msgId,
+ reqId: this.reqId,
+ conversationId: this.conversationId,
+ model: this.model,
+ });
+ }
}
closeCurrentBlock() {
@@ -1191,12 +1206,42 @@ class AnthropicStreamTranslator {
const choice = chunk.choices?.[0];
if (choice) {
const delta = choice.delta || {};
- if (delta.reasoning_content) this.emitThinkingDelta(delta.reasoning_content);
+ if (delta.reasoning_content) {
+ if (leakTraceEnabled()) {
+ log.info('LEAK_TRACE classify', {
+ channel: 'reasoning',
+ blockType: this.current?.type ?? null,
+ think: thinkMarkersIn(delta.reasoning_content),
+ sample: leakSample(delta.reasoning_content),
+ len: delta.reasoning_content.length,
+ msgId: this.msgId,
+ reqId: this.reqId,
+ conversationId: this.conversationId,
+ model: this.model,
+ });
+ }
+ this.emitThinkingDelta(delta.reasoning_content);
+ }
// Forward-compat: if a future upstream attaches a real encrypted signature
// to the reasoning stream, capture it so closeCurrentBlock round-trips the
// genuine value instead of the empty-string placeholder.
if (delta.reasoning_signature) this.pendingThinkingSignature = delta.reasoning_signature;
- if (delta.content) this.emitTextDelta(delta.content);
+ if (delta.content) {
+ if (leakTraceEnabled()) {
+ log.info('LEAK_TRACE classify', {
+ channel: 'content',
+ blockType: this.current?.type ?? null,
+ think: thinkMarkersIn(delta.content),
+ sample: leakSample(delta.content),
+ len: delta.content.length,
+ msgId: this.msgId,
+ reqId: this.reqId,
+ conversationId: this.conversationId,
+ model: this.model,
+ });
+ }
+ this.emitTextDelta(delta.content);
+ }
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) this.emitToolCallDelta(tc);
}
@@ -1528,7 +1573,7 @@ export async function handleMessages(body, context = {}) {
'X-Accel-Buffering': 'no',
},
async handler(realRes) {
- const translator = new AnthropicStreamTranslator(realRes, msgId, requestedModel, cachePolicy, inputEstimate, body.stop_sequences);
+ const translator = new AnthropicStreamTranslator(realRes, msgId, requestedModel, cachePolicy, inputEstimate, body.stop_sequences, context.reqId, context.conversation_id);
const captureRes = createCaptureRes(translator, realRes);
// Forward client disconnect so the upstream cascade is cancelled.
diff --git a/src/leak-trace.js b/src/leak-trace.js
new file mode 100644
index 00000000..5ff1734b
--- /dev/null
+++ b/src/leak-trace.js
@@ -0,0 +1,40 @@
+// Reasoning/content boundary tracing — gate-controlled OBSERVABILITY.
+//
+// WINDSURFAPI_LEAK_TRACE=1 emits structured log lines at the reasoning/content
+// boundary so the live-only leak — model reasoning spilling into the content
+// channel (PRs #238/#241/#243 rescued empty answers and rerouted a ' thinking'
+// prefix, but never reproduced the leak in a forced harness) — can be caught in
+// production. Default OFF: when disabled the hot path does nothing beyond one
+// env read + boolean compare and nothing else changes.
+//
+// Every line is prefixed `LEAK_TRACE` and carries structured fields. The three
+// call sites:
+// - devin-connect-openai.js streamChatWithEmptyRetry: per raw stream event —
+// channel (content/reasoning), think markers, truncated sample.
+// - handlers/messages.js AnthropicStreamTranslator: block open + per-delta
+// classification (blockType/channel).
+// - handlers/chat.js streamResponse: settle summary — what went to content vs
+// reasoning and whether the thinking→content fallback fired.
+import { safeLogValue } from './log-safety.js';
+
+export const LEAK_TRACE_ENV = 'WINDSURFAPI_LEAK_TRACE';
+
+export function leakTraceEnabled(env = process.env) {
+ return String(env[LEAK_TRACE_ENV] ?? '').trim() === '1';
+}
+
+// Marker strings that indicate model reasoning present in a text channel.
+// DeepSeek wraps reasoning in …; Kimi K2 emits ◁think▷.
+// Extend here when another model ships a different marker.
+export const THINK_MARKERS = ['', '', '◁think▷'];
+
+export function thinkMarkersIn(text) {
+ const found = THINK_MARKERS.filter((m) => String(text ?? '').includes(m));
+ return found.length ? found : null;
+}
+
+// Bounded text sample for logs (never the whole payload). Reuses the repo's
+// existing log-boundary sanitizer (control chars → '·', slice to max, '…').
+export function leakSample(text, max = 120) {
+ return safeLogValue(text, max);
+}
diff --git a/test/leak-trace.test.js b/test/leak-trace.test.js
new file mode 100644
index 00000000..11c2e112
--- /dev/null
+++ b/test/leak-trace.test.js
@@ -0,0 +1,158 @@
+// Stage E: WINDSURFAPI_LEAK_TRACE — structured reasoning/content boundary logs.
+//
+// The gate is read per call (leakTraceEnabled()), so a single file toggles
+// process.env.WINDSURFAPI_LEAK_TRACE between tests. Logger stub style follows
+// test/retry-rescue-budget-split.test.js (message-first variadic log), except
+// the fields object is JSON-stringified so field-level assertions work (the
+// repo sample flattens objects to '[object Object]').
+import { afterEach, beforeEach, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { addAccountByKey, removeAccount } from '../src/auth.js';
+import { log } from '../src/config.js';
+import { handleChatCompletions, __resetConnectDeps, __setConnectDeps } from '../src/handlers/chat.js';
+import { handleMessages } from '../src/handlers/messages.js';
+import { toChatCompletion, __setStreamChatForTest } from '../src/devin-connect-openai.js';
+
+let captured = [];
+const originalInfo = log.info;
+const originalWarn = log.warn;
+
+function captureLogs() {
+ captured = [];
+ log.info = (...args) => captured.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
+ log.warn = (...args) => captured.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
+}
+const leakLines = () => captured.filter((l) => l.includes('LEAK_TRACE'));
+const sseFrame = (obj) => `data: ${JSON.stringify(obj)}\n\n`;
+
+function fakeStreamRes() {
+ const listeners = new Map();
+ return {
+ body: '', writableEnded: false,
+ write(chunk) { this.body += String(chunk); return true; },
+ end(chunk) {
+ if (chunk) this.write(chunk);
+ this.writableEnded = true;
+ for (const cb of listeners.get('close') || []) cb();
+ },
+ on(event, cb) {
+ if (!listeners.has(event)) listeners.set(event, []);
+ listeners.get(event).push(cb);
+ return this;
+ },
+ };
+}
+
+beforeEach(() => {
+ __resetConnectDeps();
+ __setStreamChatForTest(null);
+ captureLogs();
+});
+
+afterEach(() => {
+ log.info = originalInfo;
+ log.warn = originalWarn;
+ __resetConnectDeps();
+ __setStreamChatForTest(null);
+ delete process.env.WINDSURFAPI_LEAK_TRACE;
+ delete process.env.DEVIN_CONNECT;
+ delete process.env.DEVIN_CONNECT_RETRY_ON_EMPTY_MS;
+});
+
+// --- devin-connect-openai.js: raw stream events at the channel boundary ---
+
+it('gate ON: raw stream events logged with channel/think/sample/reqId/account', async () => {
+ process.env.WINDSURFAPI_LEAK_TRACE = '1';
+ process.env.DEVIN_CONNECT_RETRY_ON_EMPTY_MS = '0';
+ __setStreamChatForTest(async function* () {
+ yield { type: 'reasoning', text: 'let me think ' };
+ yield { type: 'content', text: 'x'.repeat(500) };
+ yield { type: 'finish', reason: 'stop' };
+ });
+ const out = await toChatCompletion({ model: 'm' }, { reqId: 'r1', account: 'acc-1' });
+ assert.equal(out.body.choices[0].message.content, 'x'.repeat(500));
+ const lines = leakLines();
+ assert.equal(lines.length, 2, 'one stream-event line per text-bearing event');
+ const reasoning = lines.find((l) => l.includes('"channel":"reasoning"'));
+ const content = lines.find((l) => l.includes('"channel":"content"'));
+ assert.ok(reasoning, 'reasoning event logged');
+ assert.ok(content, 'content event logged');
+ assert.ok(reasoning.includes('LEAK_TRACE stream-event'), 'message prefix');
+ assert.ok(reasoning.includes('"think":[""]'), 'think marker detected');
+ assert.ok(content.includes('"len":500'), 'len field');
+ assert.ok(content.includes('"sample":"') && content.includes('…'), 'sample truncated');
+ assert.ok(content.includes('"reqId":"r1"') && content.includes('"account":"acc-1"'), 'reqId/account threaded via opts');
+});
+
+it('gate OFF: zero LEAK_TRACE lines on the same hot path', async () => {
+ delete process.env.WINDSURFAPI_LEAK_TRACE;
+ process.env.DEVIN_CONNECT_RETRY_ON_EMPTY_MS = '0';
+ __setStreamChatForTest(async function* () {
+ yield { type: 'reasoning', text: 'think hard' };
+ yield { type: 'content', text: 'answer' };
+ yield { type: 'finish', reason: 'stop' };
+ });
+ await toChatCompletion({ model: 'm' }, { reqId: 'r1', account: 'acc-1' });
+ assert.equal(leakLines().length, 0, 'no leak-trace lines when WINDSURFAPI_LEAK_TRACE is off');
+});
+
+// --- messages.js: block classification / rerouting decisions ---
+
+it('gate ON: messages.js classify + block-start logs with channel/reqId fields', async () => {
+ process.env.WINDSURFAPI_LEAK_TRACE = '1';
+ const fakeUpstream = async (body, ctx) => ({
+ status: 200,
+ stream: true,
+ handler: async (captureRes) => {
+ captureRes.write(sseFrame({ choices: [{ delta: { role: 'assistant' } }] }));
+ captureRes.write(sseFrame({ choices: [{ delta: { reasoning_content: 'deep think ' } }] }));
+ captureRes.write(sseFrame({ choices: [{ delta: { content: 'hi there' } }] }));
+ captureRes.write(sseFrame({ choices: [{ delta: {}, finish_reason: 'stop' }] }));
+ captureRes.write('data: [DONE]\n\n');
+ },
+ });
+ const result = await handleMessages(
+ { model: 'm', stream: true, messages: [{ role: 'user', content: 'q' }], max_tokens: 10 },
+ { handleChatCompletions: fakeUpstream, reqId: 'r1', conversation_id: 'c1' },
+ );
+ const res = fakeStreamRes();
+ await result.handler(res);
+ const lines = leakLines();
+ assert.ok(lines.some((l) => l.includes('LEAK_TRACE classify') && l.includes('"channel":"reasoning"')), 'reasoning classification logged');
+ assert.ok(lines.some((l) => l.includes('LEAK_TRACE classify') && l.includes('"channel":"content"')), 'content classification logged');
+ assert.ok(lines.some((l) => l.includes('LEAK_TRACE block-start')), 'block-start logged');
+ assert.ok(lines.some((l) => l.includes('"reqId":"r1"')), 'reqId threaded into translator');
+ assert.ok(lines.some((l) => l.includes('"think":[""]')), 'think marker in reasoning sample');
+});
+
+// --- chat.js: settle summary (what went to content vs reasoning) ---
+
+it('gate ON: chat.js streamResponse settle log with content/reasoning sizes', async () => {
+ process.env.WINDSURFAPI_LEAK_TRACE = '1';
+ process.env.DEVIN_CONNECT = '1';
+ const key = `leak-trace-token-${Math.random().toString(36).slice(2)}`;
+ const acct = addAccountByKey(key, 'leak-trace');
+ try {
+ __setConnectDeps({
+ streamChatCompletion: async (params, send) => {
+ send({ id: 'c1', object: 'chat.completion.chunk', created: 1, model: params.model, choices: [{ index: 0, delta: { reasoning_content: 'deep reasoning' }, finish_reason: null }] });
+ send({ id: 'c1', object: 'chat.completion.chunk', created: 1, model: params.model, choices: [{ index: 0, delta: { content: 'hello world' }, finish_reason: null }] });
+ return { id: 'c1', object: 'chat.completion.chunk', created: 1, model: params.model, content: 'hello world', reasoning: 'deep reasoning', finish_reason: 'stop', usage: { total_tokens: 9 }, billing: {} };
+ },
+ });
+ const result = await handleChatCompletions(
+ { model: 'swe-1-6-slow', stream: true, messages: [{ role: 'user', content: 'hi' }] },
+ { callerKey: '' },
+ );
+ const res = fakeStreamRes();
+ await result.handler(res);
+ const settle = leakLines().find((l) => l.includes('LEAK_TRACE settle'));
+ assert.ok(settle, 'settle log present');
+ assert.ok(settle.includes('"contentChars":11'), `content size 11 (hello world), got: ${settle}`);
+ assert.ok(settle.includes('"reasoningChars":14'), `reasoning size 14 (deep reasoning), got: ${settle}`);
+ assert.ok(settle.includes('"rerouted":false'), 'reroute flag false');
+ assert.ok(settle.includes('"reqId":'), 'reqId in settle fields');
+ } finally {
+ removeAccount(acct.id);
+ }
+});
diff --git a/test/mutations/leak-trace.json b/test/mutations/leak-trace.json
new file mode 100644
index 00000000..89a96571
--- /dev/null
+++ b/test/mutations/leak-trace.json
@@ -0,0 +1,48 @@
+{
+ "tests": ["test/leak-trace.test.js"],
+ "expectBaselinePass": 4,
+ "mutations": [
+ {
+ "name": "gate inverted — tracing never fires",
+ "file": "src/leak-trace.js",
+ "anchor": " return String(env[LEAK_TRACE_ENV] ?? '').trim() === '1';",
+ "replacement": " return String(env[LEAK_TRACE_ENV] ?? '').trim() === '0';",
+ "expectCaught": true
+ },
+ {
+ "name": "gate removed — tracing always on",
+ "file": "src/leak-trace.js",
+ "anchor": " return String(env[LEAK_TRACE_ENV] ?? '').trim() === '1';",
+ "replacement": " return true;",
+ "expectCaught": true
+ },
+ {
+ "name": "think-marker detection neutered",
+ "file": "src/leak-trace.js",
+ "anchor": " return found.length ? found : null;",
+ "replacement": " return null;",
+ "expectCaught": true
+ },
+ {
+ "name": "stream-event channel field dropped",
+ "file": "src/devin-connect-openai.js",
+ "anchor": " channel: ev.type,",
+ "replacement": " channelKind: ev.type,",
+ "expectCaught": true
+ },
+ {
+ "name": "classify log demoted to debug",
+ "file": "src/handlers/messages.js",
+ "anchor": " log.info('LEAK_TRACE classify', {\n channel: 'reasoning',",
+ "replacement": " log.debug('LEAK_TRACE classify', {\n channel: 'reasoning',",
+ "expectCaught": true
+ },
+ {
+ "name": "settle log demoted to debug",
+ "file": "src/handlers/chat.js",
+ "anchor": " log.info('LEAK_TRACE settle', {\n reqId,\n model: connectDisplayModel,",
+ "replacement": " log.debug('LEAK_TRACE settle', {",
+ "expectCaught": true
+ }
+ ]
+}