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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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(命中的标记,如 </thinking> / ◁think▷)、sample(截断样本,非全文)、
# len、reqId/account;messages 路径记 block-start/classify(blockType/channel/
# msgId/reqId);settle 时记 contentChars/reasoningChars/rerouted。默认 OFF:
# 关闭时热路径只读一次 env 标志,不改任何行为。
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 数据根目录 |
Expand Down
23 changes: 18 additions & 5 deletions src/devin-connect-openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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; }
Expand Down Expand Up @@ -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) };

Expand Down Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions src/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(); }
Expand Down Expand Up @@ -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
Expand Down
53 changes: 49 additions & 4 deletions src/handlers/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions src/leak-trace.js
Original file line number Diff line number Diff line change
@@ -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 <thinking>…</thinking>; Kimi K2 emits ◁think▷.
// Extend here when another model ships a different marker.
export const THINK_MARKERS = ['<thinking>', '</thinking>', '◁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);
}
Loading