fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED (#6791)

* fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED

Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy
files belong to #6620, not this PR); keeps only the author's changes.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(deepseek): extract done-terminator helper to keep frozen file under cap

Extracts the FINISHED-drain scheduler and finish-once guard added for
the [DONE] terminator fix (#6777) into a new
deepseek-web-done-terminator.ts module, so deepseek-web.ts stays under
its frozen line cap (1148). Behavior is unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Pitchfork-and-Torch <Pitchfork-and-Torch@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Jon Bailey
2026-07-10 17:37:05 -04:00
committed by GitHub
parent 647544b810
commit 91efacadd3
3 changed files with 97 additions and 4 deletions

View File

@@ -0,0 +1 @@
- **fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED** (#6791 — thanks @Pitchfork-and-Torch).

View File

@@ -0,0 +1,68 @@
// ── DeepSeek Web SSE "done terminator" helpers ──────────────────────────
//
// Extracted from deepseek-web.ts (frozen line-count) so the drain/guard
// state machine used to close the OpenAI-compatible SSE after DeepSeek's
// `response/status=FINISHED` event can grow without touching the frozen
// file. See #6777: upstreams that leave the HTTP body open hang OpenAI SDK
// clients that wait for `data: [DONE]` after `finish_reason: stop`.
/** How long to wait after DeepSeek `response/status=FINISHED` for trailing
* search_results before closing the OpenAI-compatible SSE. */
export const DEEPSEEK_FINISHED_DRAIN_MS = 750;
/** Wraps a stream-finishing callback so it runs at most once and never
* throws past a controller that the client already cancelled/closed. */
export function createFinishOnceGuard(finish: () => void): {
finishOnce: () => void;
hasFinished: () => boolean;
} {
let streamFinished = false;
return {
finishOnce: () => {
if (streamFinished) return;
streamFinished = true;
try {
finish();
} catch {
// Controller may already be closed if the client cancelled.
}
},
hasFinished: () => streamFinished,
};
}
/** Schedules `finishStream` after a short drain window following
* `response/status=FINISHED`, so late `search_results` payloads still get
* captured, while guaranteeing the stream always closes even if the
* upstream body stays open past that window. */
export function createFinishedDrainScheduler(
finishStream: () => void,
drainMs: number = DEEPSEEK_FINISHED_DRAIN_MS
): {
scheduleFinishAfterDrain: () => void;
clearFinishedDrain: () => void;
isDrainPending: () => boolean;
} {
let finishedDrainTimer: ReturnType<typeof setTimeout> | null = null;
const clearFinishedDrain = () => {
if (finishedDrainTimer) {
clearTimeout(finishedDrainTimer);
finishedDrainTimer = null;
}
};
const scheduleFinishAfterDrain = () => {
clearFinishedDrain();
finishedDrainTimer = setTimeout(() => {
finishedDrainTimer = null;
finishStream();
}, drainMs);
};
return {
scheduleFinishAfterDrain,
clearFinishedDrain,
isDrainPending: () => finishedDrainTimer !== null,
};
}

View File

@@ -14,6 +14,10 @@ import {
appendSearchCitations,
type DeepSeekSearchResult,
} from "./deepseek-web/stream-format.ts";
import {
createFinishOnceGuard,
createFinishedDrainScheduler,
} from "./deepseek-web-done-terminator.ts";
export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com";
const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`;
@@ -198,7 +202,7 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
}
};
const finishStream = () => {
const { finishOnce: finishStream, hasFinished } = createFinishOnceGuard(() => {
const citations = appendSearchCitations(searchResults, streamModel);
if (citations) {
ensureRole();
@@ -206,9 +210,16 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
}
ensureRole();
chunk({}, "stop");
// OpenAI-compatible clients (SDK, OpenCode) hang without this terminator.
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
});
// Do not close *immediately* on FINISHED — DeepSeek may still send
// search_results afterward. Drain briefly, then always emit
// stop + [DONE] so clients do not hang if the upstream body stays open.
const { scheduleFinishAfterDrain, clearFinishedDrain, isDrainPending } =
createFinishedDrainScheduler(finishStream);
const sendByPath = (raw: string) => {
const text = formatStreamContent(raw, streamModel);
@@ -324,19 +335,32 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
}
}
// Do not close on FINISHED — DeepSeek may still send search_results afterward.
if (p === "response/status" && v === "FINISHED") {
scheduleFinishAfterDrain();
continue;
}
// Any other post-FINISHED payload extends the drain window so we
// still capture late search_results before closing.
if (isDrainPending()) {
scheduleFinishAfterDrain();
}
}
}
} catch (err) {
controller.error(err);
clearFinishedDrain();
if (!hasFinished()) {
controller.error(err);
}
return;
}
finishStream();
},
cancel() {
// Best-effort: cancel upstream reader if the client aborts mid-stream.
// finishStream is not required here — the controller is already cancelled.
},
},
{ highWaterMark: 16384 }
);