src/devin-connect-openai.js
Rama master · réplica de lectura del repositorio original.
/**
* DEVIN_CONNECT → OpenAI ChatCompletion adapter.
*
* Bridges the structured stream from devin-connect.js (content / reasoning /
* finish / usage events) into the two OpenAI-compatible response shapes the rest
* of the proxy already emits:
*
* - non-stream: a single `chat.completion` object
* - stream: a sequence of `chat.completion.chunk` SSE frames + [DONE]
*
* The output shape is matched field-for-field to the Cascade path in
* handlers/chat.js (role-priming chunk, reasoning_content before content, a
* finish_reason:'stop' chunk, then a usage-only chunk) so a client can't tell
* which backend served the request. Reasoning maps to `reasoning_content`,
* which OpenAI-style clients hide by default — see handlers/chat.js:737.
*
* Pure translation: no network of its own, it only consumes streamChat().
*/
import { randomUUID } from 'crypto';
import { filterToolCallsByAllowlist } from './handlers/chat.js';
import { streamChat as realStreamChat, isRetryable, messageText, applyOutputCap } from './devin-connect.js';
import { ToolCallStreamParser, parseToolCallsFromText, isWeakEmulationModel } from './handlers/tool-emulation.js';
import { log } from './config.js';
import { safeLogValue } from './log-safety.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';
import { ThinkTextClassifier } from './response-classifier.js';
import { budgetExceededError } from './request-budget.js';
import { bumpConnect } from './devin-connect-metrics.js';
import { toClientToolCallId, restoreUpstreamToolIds } from './tool-ids.js';
// Gate for the leading think-tag reroute (Item 1, loop break). The classifier
// lives HERE, in the connect layer, so the DEVIN_CONNECT_ prefix is accurate:
// only the connect backend's streams are reclassified. Default OFF — the
// reroute is a behavior change and must be opted into per deployment.
function thinkTextRerouteEnabled() {
return String(process.env.DEVIN_CONNECT_THINKTEXT_REROUTE || '') === '1';
}
// Apply the Cline compat tool-arg shim when active: normalize an arguments
// string @ai-sdk/openai-compatible would reject (empty / whitespace / non-JSON)
// to "{}" so a parameterless tool call isn't silently dropped (vercel/ai#6687).
// A no-op passthrough when inactive → byte-identical for every non-Cline client.
function compatArgs(raw, active) {
// Inactive → preserve the exact legacy expression (`raw || '{}'`) so the
// default path is byte-identical.
if (!active) return raw || '{}';
// Active → normalize the RAW value (not the pre-coalesced one) so an empty
// string, whitespace, or malformed JSON is counted as a real repair.
const fixed = normalizeToolCallArgs(raw);
const legacy = raw || '{}';
if (fixed !== legacy) recordArgRepair();
return fixed;
}
// streamChat is injectable so the adapter can be unit-tested without touching
// the network — mirrors the __set…ForTest convention in windsurf-api.js.
let streamChatImpl = realStreamChat;
export function __setStreamChatForTest(fn) {
streamChatImpl = typeof fn === 'function' ? fn : realStreamChat;
}
// ── retry-on-empty (fable capacity-jitter self-heal) ────────────────────────
// fable (and other capacity-jittered upstream models) occasionally return a
// COMPLETED turn that carries no answer at all: finish_reason 'stop',
// completion_tokens ≤ 2, and zero content / reasoning / tool_call deltas. This
// is PROBABILISTIC upstream capacity jitter — NOT a deterministic tool-count
// threshold (that theory was disproven; the code never trims tools) and NOT an
// outage (kimi's 502 path is a different fault). The correct heal is to simply
// re-issue the identical request a bounded number of times, since a fresh
// attempt usually lands a real answer.
//
// Because an empty reply yields ONLY a terminal finish event (no content), the
// wrapper below forwards every delta LIVE — zero buffering, zero added latency
// on the overwhelmingly common non-empty path — and merely holds the single
// finish event to decide, once the stream has drained, whether anything real
// was produced. It retries ONLY when the turn emitted literally nothing, so it
// is safe for both the streaming and non-stream callers (which each just
// iterate this wrapper instead of the raw primitive). It deliberately does NOT
// merge into isRetryable() (UPSTREAM_INTERNAL stays non-retryable by design) and
// never trims tools.
function retryOnEmptyEnabled(env = process.env) {
// Default ON: an empty completion is always a degenerate result and the retry
// is bounded + only fires when the turn yielded nothing. Only an explicit
// off-switch disables it.
const v = String(env.DEVIN_CONNECT_RETRY_ON_EMPTY ?? '').trim().toLowerCase();
return v !== '0' && v !== 'off' && v !== 'false' && v !== 'no';
}
function retryOnEmptyMax(env = process.env) {
const raw = Number(env.DEVIN_CONNECT_RETRY_ON_EMPTY_MAX);
return Number.isFinite(raw) && raw >= 0 ? raw : 2;
}
function retryOnEmptyBaseMs(env = process.env) {
const raw = Number(env.DEVIN_CONNECT_RETRY_ON_EMPTY_MS);
return Number.isFinite(raw) && raw >= 0 ? raw : 350;
}
/**
* Decide whether a drained stream was the pathological empty completion.
* @param {object} finishEv the terminal finish event (may be null if none seen)
* @param {boolean} sawContent true if any non-empty content/reasoning delta arrived
*/
function isEmptyCompletion(finishEv, sawContent) {
// The AUTHORITATIVE signal is that the turn produced ZERO usable output:
// no content delta, no reasoning delta, no tool call. `sawContent` already
// captures "any non-empty content/reasoning arrived", so if it's true the
// turn is not empty. This is what actually breaks OpenCode/agent loops.
if (sawContent || !finishEv) return false;
// A native/emulated tool call is a real answer even with no visible text.
if (finishEv.toolCalls && finishEv.toolCalls.length) return false;
// Only a clean 'stop' (or an absent finish reason — a clean drain with no
// signal) counts. 'length' / 'tool_calls' / 'content_filter' are real
// terminal states we must not paper over with a retry.
if (finishEv.reason != null && finishEv.reason !== 'stop') return false;
// NOTE: completion_tokens is deliberately NOT used as a gate. Live paid probes
// (2026-07-08, fable-5-medium + 11–14 tools) showed genuine empty replies with
// completion_tokens of 3/5/8/9 — an earlier `ct <= 2` gate silently vetoed the
// retry on every one of them (15/15 empty, zero heals). The empty OUTPUT
// (sawContent === false with a clean stop) is the real signature; ct only rides
// the log line below for diagnostics.
return true;
}
// ── #236 prose tool-intent detector (log-only) ─────────────────────────────
// The rescue gate below requires `sawReasoning && !sawText`. A reasoning-effort
// `-none` selector (glm-5-2-none and its siblings) can NEVER satisfy it: the
// upstream emits no reasoning events at all, so a stalled turn in that family
// arrives as TEXT or as nothing, never as reasoning. The reported shape is the
// same trap one channel over — the model narrates the tool call in visible text,
// finishes with `stop` and zero tool calls, and the client waits for a call that
// never comes.
//
// We deliberately do NOT retry on it. "The model answered in prose" and "the
// model stalled mid-intent" are indistinguishable without a capture of a real
// stall, and a false-positive nudge would rewrite a legitimate answer (#236
// thread, 2026-08-03: 60+ direct turns, zero stalls, so the trigger is not even
// established). What that thread is missing is the capture — so this detector
// produces one. It logs and counts; it never alters the wire.
//
// The criterion is narrow on purpose, and its last two conditions are what keep
// ordinary final answers out of the log:
// - the request declared tools (a request without tools has nothing to stall on);
// - finish is `stop`/null with ZERO tool calls (a real call, or a `length` /
// `content_filter` terminal state, is not this shape);
// - the turn does not END like a finished sentence. A completed answer ends in
// `.`/`!`/`?` (or the CJK equivalents); a turn that stops mid-intent ends in
// `:`, an ellipsis, or nothing at all. This is what separates
// "Let me read the file:" (stall) from "I used read_file to inspect it."
// (a normal summary) — the latter names a tool too, and would otherwise
// dominate the signal;
// - and the tail then names one of THIS request's tools, or carries an intent
// phrase. Tool names are matched with explicit ASCII boundaries so `bash`
// does not match inside `bashful`, and `read_file` does not match inside
// `read_files`.
//
// Accepted false negatives: a stall whose text happens to end in a period is not
// detected. Keeping the counter clean matters more than covering every phrasing,
// because the counter is what an operator will alarm on.
const PROSE_INTENT_TAIL_MAX = 400;
// Sentence-final punctuation, optionally followed by closing quotes/brackets.
const SENTENCE_FINAL = /[.!?。!?]["'”’`)\]]*\s*$/;
// Future/present intent markers. `let me` is excluded when followed by "know" —
// "Let me know if you need anything else" is a closing, not an intent.
const PROSE_INTENT_VERB = /\b(?:let me(?!\s+know\b)|i(?:'ll| will| am going to|'m going to| need to| should)|next,? i|now,? i)\b|(?:让我|我来|我先|接下来我|我将|我需要|正在)/i;
function proseIntentDetectEnabled(env = process.env) {
// Log-only, so it is on by default: a detector that has to be switched on
// before it can capture the shape it exists to capture captures nothing.
return String(env.DEVIN_CONNECT_PROSE_INTENT_DETECT ?? '1') !== '0';
}
/** Tool names declared by the request, in declaration order. */
function requestToolNames(tools) {
if (!Array.isArray(tools)) return [];
const names = [];
for (const tool of tools) {
const name = tool?.function?.name || tool?.name;
if (typeof name === 'string' && name.trim()) names.push(name.trim());
}
return names;
}
/**
* Judge a FINISHED turn for the #236 "narrated a tool call, emitted none" shape.
* Returns a signal string (`tool_name:<name>` or `intent_verb:<phrase>`) or null.
* Pure and synchronous — the caller only logs/counts the result.
*/
export function detectProseToolIntent({ text, toolNames = [], finishEv } = {}) {
if (!finishEv) return null;
if (finishEv.reason != null && finishEv.reason !== 'stop') return null;
if (finishEv.toolCalls && finishEv.toolCalls.length) return null;
const full = String(text || '');
if (!full.trim()) return null;
const tail = full.slice(-PROSE_INTENT_TAIL_MAX);
if (SENTENCE_FINAL.test(tail)) return null;
for (const name of toolNames) {
if (!name || name.length < 3) continue;
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`(^|[^A-Za-z0-9_])${escaped}([^A-Za-z0-9_]|$)`, 'i').test(tail)) {
return `tool_name:${name}`;
}
}
const verb = tail.match(PROSE_INTENT_VERB);
return verb ? `intent_verb:${verb[0].trim().toLowerCase()}` : null;
}
/**
* Log/count the #236 signal for a turn that has ALREADY been delivered.
*
* MUST run at the adapter layer, after tool-call emulation: before the parser
* strips `<tool_call>` markup, a normal emulated call looks like prose that
* stops mid-intent, so every emulated call was counted as a stall (the exact
* traffic the counter exists to measure). The caller passes the DELIVERED text
* and the calls it actually emitted, so a delivered call can never be reported
* as "narrated one, emitted none".
*/
function reportProseToolIntent({ params, text, finishEv, reasoning }) {
if (!proseIntentDetectEnabled()) return;
if (!Array.isArray(params?.tools) || params.tools.length === 0) return;
const toolNames = requestToolNames(params.tools);
const signal = detectProseToolIntent({ text, toolNames, finishEv });
if (!signal) return;
bumpConnect('prose_tool_intent');
log.info(
`DEVIN_CONNECT: prose tool intent without a call (#236) — `
+ `model=${safeLogValue(params?.model)}, finish=${finishEv?.reason ?? 'null'}, `
+ `signal=${signal}, reasoning=${Boolean(reasoning)}, tools=${toolNames.length}, `
+ `sample=${JSON.stringify(leakSample(text))}`,
);
}
/**
* Transparent wrapper around streamChatImpl that heals probabilistic empty
* completions by re-issuing the identical request, and rescues thinking-only
* traps (swe-1-7 declaring tool intent in reasoning without emitting the call)
* by appending a corrective user nudge and dropping empty assistant turns.
* Yields the same event stream as streamChat; on a non-empty turn it is a pass-through.
*/
// There used to be a `rescueThinkingOnly` option here. It was removed in v3.9.20 because it
// had become dead configuration: #241 moved the real gate INSIDE this function (`hasTools`,
// derived from the request), after which both call sites passed a literal `true` and nothing —
// production or test — ever passed `false`. A knob with one reachable value is worse than no
// knob: it reads as a variation point that a reviewer must reason about, and any mutation
// 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.
// Egress/ingress boundary for tool-call ids. The client received a normalized
// `call_…` id (see toClientToolCallId); the upstream issued its own dialect
// (`get_weather:0#<uuid>` on swe-2). Restore the original id on a COPY of the
// request right before the wire encode, so native history replay (#6
// ChatToolCall / #7 tool_call_id) is byte-identical to what the upstream
// issued. Everything upstream of this point — session continuity, cache keys,
// the response body — sees the client-visible id.
function withUpstreamToolIds(params) {
const messages = restoreUpstreamToolIds(params?.messages);
return messages === params?.messages ? params : { ...params, messages };
}
async function* streamChatWithEmptyRetry(params, { env = process.env } = {}, opts = {}) {
// Route-owned request budget. Each internal attempt is one upstream turn; the
// cap is per REQUEST, shared with the handler's account failover, so the
// empty/rescue retries can no longer multiply into the measured 45/90-turn
// products. Backoffs are clamped to the remaining deadline.
const budget = opts?.budget || null;
// 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
// weak models we do NOT retry on empty (short-circuit). Non-weak models keep the
// bounded retry, where an empty is more likely genuine capacity jitter.
const weak = isWeakEmulationModel(params?.model || '');
const max = (retryOnEmptyEnabled(env) && !weak) ? retryOnEmptyMax(env) : 0;
// Same sanitisation as every sibling knob in this file. Without it `Infinity` and
// `1e9` both defeated the `rescueAttempt < rescueMax` bound — and the loop uses
// `continue`, so one client request became an unbounded sequence of upstream calls
// that burns the account into a rate limit. A non-numeric value produced NaN,
// which silently turned the whole feature OFF with no log line.
//
// The rescue also honours the empty-retry master off switch: an operator who set
// DEVIN_CONNECT_RETRY_ON_EMPTY=0 to stop speculative re-issues on a rate-limited
// account meant that, and it would be a surprise for a new speculative re-issue to
// ignore it. RESCUE_MAX=0 remains the way to disable just this one.
// Number.isFinite alone is not enough: 1e9 passes it and still hangs the request
// for hours of upstream calls (measured — the probe had to be killed). A rescue is
// a speculative re-issue against a live account, so its ceiling is hard.
const RESCUE_MAX_CEILING = 5;
const rescueRaw = Number(env.DEVIN_CONNECT_RESCUE_MAX);
const rescueConfigured = Number.isFinite(rescueRaw) && rescueRaw >= 0
? Math.min(rescueRaw, RESCUE_MAX_CEILING)
: 2;
// Weak models are excluded for the same measured reason the empty-retry is: paid
// E2E proved retrying the fable family never heals it and only triples upstream
// load into a 3h rate limit. Whether fable produces reasoning-only finishes is
// unproven either way, so this errs toward the documented account-protection.
const rescueMax = (retryOnEmptyEnabled(env) && !weak) ? rescueConfigured : 0;
// Cap for the reasoning digest: the nudge quotes only the END of the reasoning,
// sliced once at nudge time (the consumers already hold the full reasoning text,
// so per-chunk trimming would only churn the GC). 0 disables the digest; NaN
// (a genuinely non-numeric value like `abc`) falls back to the default.
//
// EMPTY STRING IS NOT NaN: Number('') === 0, so `…MAX_CHARS=` with nothing after
// the `=` disables the digest rather than falling back. That is the SAME convention
// the three sibling knobs in this file already have (RETRY_ON_EMPTY_MAX → 0 retries,
// RETRY_ON_EMPTY_MS → 0 backoff, RESCUE_MAX → rescue off), all measured, so it is
// left consistent rather than special-cased here — but it is written down because
// the previous version of this comment claimed the opposite.
//
// The ceiling exists for the reason RESCUE_MAX_CEILING does (see rescueConfigured above):
// Number.isFinite lets `1e9` through. Measured — reasoning of 50000 chars with `1e9` set
// produced a 50089-byte nudge, i.e. no cap at all, on EVERY rescue of that request.
// Unlike RESCUE_MAX this cannot hang the request (it is one slice, not an unbounded loop),
// so the ceiling is not an account-protection bound; it bounds how large a single upstream
// body one env typo can produce. 32000 is a JUDGEMENT (16x the default, far above any
// plausible tuning), NOT a measured upstream limit — the real upstream body limit has
// never been probed.
//
// Math.floor is load-bearing, not tidiness. The single consumer is `slice(-n)`, and for
// 0 < n < 1 slice truncates the argument toward zero: `slice(-0.5)` === `slice(0)` ===
// THE WHOLE STRING. So a fractional cap under 1 inverted into "no cap at all" — measured,
// MAX_CHARS=0.5 shipped all 50000 chars, the exact failure the ceiling was added to stop.
// Clamping alone did not cover it because Math.min(0.5, 32000) is correctly 0.5.
const DIGEST_MAX_CEILING = 32000;
const digestRaw = Number(env.DEVIN_CONNECT_RESCUE_REASONING_MAX_CHARS);
const reasoningDigestMaxChars = Number.isFinite(digestRaw) && digestRaw >= 0
? Math.floor(Math.min(digestRaw, DIGEST_MAX_CEILING))
: 2000;
let attemptParams = params;
// Two speculative arms, two budgets (#240). They used to share the loop counter: the
// rescue arm has always had its own `rescueAttempt`/`rescueMax` pair, but reaching the
// next iteration via `continue` also advanced the `for (…; ; attempt++)` counter, and
// `attempt` was what the EMPTY arm's budget was measured against. So a rescue chain
// silently spent the empty arm's budget — one-way, since the empty arm never touched
// `rescueAttempt`. Measured at the defaults (max 2, rescue 2): two rescues followed by
// an empty completion delivered the empty answer to the client with ZERO retries, while
// the same empty with no preceding rescue healed on the first retry.
//
// That the rescue arm carries its own counter is the evidence this was never a "one
// shared pot" policy — a shared pot would not need the second counter. It was an
// artifact of counting in the for-header.
//
// The TOTAL ceiling is deliberately unchanged: 1 + max + rescueMax upstream calls per
// client request. That ceiling is the account protection (the fable paid-E2E lesson —
// retries that never heal only triple upstream load into a rate limit), and it was
// already reachable before this change in the empty-first order. Splitting the counters
// makes the empty budget independent of ORDER; it does not raise what may be spent.
// #236's detector is NOT here: it runs in the adapters, after emulation, on
// the text and calls the client actually received. Judging the raw upstream
// tail here counted every normal emulated `<tool_call>` turn as a stall
// (the markup is stripped only downstream).
const hasTools = Boolean(params?.tools?.length);
let rescueAttempt = 0;
let emptyAttempt = 0;
for (;;) {
let sawContent = false;
let sawText = false;
let sawReasoning = false;
let sawReasoningText = '';
let finishEv = null;
// P-04: reasoning is provisional until this attempt is committed. A rescue
// REPLACES the attempt and a delta already sent cannot be retracted, so a
// reasoning-only attempt that is later abandoned used to leave its whole
// trace on the wire while the returned/cached/continuity value kept only
// the final attempt. Hold reasoning while a rescue is still possible and
// release it only when the attempt can no longer be replaced: the first
// text delta (sawText closes the rescue gate) or the terminal finish. The
// rescue branch below drops the buffer together with the attempt.
const rescuePossible = hasTools && rescueAttempt < rescueMax;
let pendingReasoning = [];
// The thinking signature (opt-in decode, DEVIN_CONNECT_SIGNATURE_TAG) seals
// this attempt's reasoning, so it is held and released with it: committed
// together, or dropped together when a rescue replaces the attempt.
let pendingSignature = '';
// Item 1 (loop break): leading think-tagged CONTENT is reclassified into the
// reasoning channel HERE, at the stream-event level, BEFORE the rescue
// decision below. A whole turn of ` thinking…` therefore stays a
// reasoning-only stream.
// Honest rescue interplay (corrected after review): such a turn does NOT
// fire the #238 rescue — the :284 nudge path requires tools, and
// isEmptyCompletion reads sawContent, which the thinking branch below sets
// at :238. The real outcome: reasoning delivered as thinking blocks, text
// empty, no retry. That is accepted: the reasoning is not lost (the client
// receives thinking blocks, not the #238 whole-turn vanish), and sawContent
// genuinely means "upstream said something", which is true here.
// Deliberately NOT teaching isEmptyCompletion to read sawText — that would
// redraw the #238/#241 rescue boundary for a cosmetic gain.
// (Known limitation: only the ` thinking`…` dialect is recognized; Kimi K2's
// family uses `◁think▷…◁/think▷` — extending the classifier to that dialect
// is left for a future PR.)
const thinkClassifier = thinkTextRerouteEnabled() ? new ThinkTextClassifier() : null;
// One turn per upstream call, consumed immediately before it. The cap is
// hard: when it is reached the generator throws a 504-class error instead
// of starting another turn.
if (budget && !budget.takeTurn()) {
throw budgetExceededError(
budget.expired()
? `request budget exhausted before DEVIN_CONNECT attempt (${budget.describe()})`
: `request upstream-turn cap reached before DEVIN_CONNECT attempt (${budget.describe()})`,
);
}
const upstreamParams = withUpstreamToolIds(attemptParams);
const callParams = budget
? {
...upstreamParams,
// devin-connect.js treats deadlineMs as the total wall clock for THIS
// stream. Give it what the request has left, never more.
deadlineMs: Math.min(
Number.isFinite(upstreamParams?.deadlineMs) && upstreamParams.deadlineMs > 0
? upstreamParams.deadlineMs
: Infinity,
budget.remainingMs(),
),
}
: upstreamParams;
for await (const ev of streamChatImpl(callParams)) {
if ((ev.type === 'content' || ev.type === 'reasoning') && 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,
});
}
if (ev.type === 'content' && thinkClassifier) {
const routed = thinkClassifier.feed(ev.text);
if (routed.thinking) {
sawContent = true;
sawReasoning = true;
sawReasoningText += routed.thinking;
if (rescuePossible && !sawText) pendingReasoning.push(routed.thinking);
else yield { type: 'reasoning', text: routed.thinking };
}
if (routed.text) {
// First text delta: the rescue gate (`!sawText`) is now closed, so
// this attempt is committed — release its provisional reasoning
// before the text it preceded.
for (const text of pendingReasoning) yield { type: 'reasoning', text };
pendingReasoning = [];
if (pendingSignature) { yield { type: 'signature', reasoning_signature: pendingSignature }; pendingSignature = ''; }
sawContent = true;
sawText = true;
yield { type: 'content', text: routed.text };
}
} else if (ev.type === 'content' || ev.type === 'reasoning') {
if (ev.text) {
sawContent = true;
if (ev.type === 'content') {
// First text delta commits the attempt — see the classifier branch.
for (const text of pendingReasoning) yield { type: 'reasoning', text };
pendingReasoning = [];
if (pendingSignature) { yield { type: 'signature', reasoning_signature: pendingSignature }; pendingSignature = ''; }
sawText = true;
} else {
sawReasoning = true;
sawReasoningText += ev.text;
if (rescuePossible && !sawText) {
pendingReasoning.push(ev.text);
continue;
}
}
}
yield ev;
} else if (ev.type === 'finish') {
finishEv = ev; // hold: decide retry after the stream drains
} else if (ev.type === 'signature' && rescuePossible && !sawText) {
pendingSignature += ev.reasoning_signature || '';
} else {
yield ev;
}
}
// Flush anything the classifier still holds BEFORE the rescue decision: an
// unterminated/undecided tail is delivered as text (visible beats dropped),
// and it must count as real text for the rescue gate above.
if (thinkClassifier) {
const rest = thinkClassifier.flush();
if (rest) {
// The flushed tail is text, so the attempt is committed for the same
// reason as a normal content delta.
for (const text of pendingReasoning) yield { type: 'reasoning', text };
pendingReasoning = [];
if (pendingSignature) { yield { type: 'signature', reasoning_signature: pendingSignature }; pendingSignature = ''; }
sawContent = true;
sawText = true;
yield { type: 'content', text: rest };
}
}
// swe-1-7 (Kimi K2 fine-tune) intermittently spends the whole turn in reasoning
// declaring tool intent without emitting the call; a corrective nudge measurably
// (24/24 live probe) forces emission; empty assistant turns poison upstream into
// repeating empty turns.
// NOTE: Rescue triggers only when tools are present in the request (params.tools?.length > 0).
// Plain chat requests without tools legitimately end in reasoning/text, so rescue is skipped.
const isStopOrNull = finishEv && (finishEv.reason == null || finishEv.reason === 'stop');
const hasNoToolCalls = !finishEv?.toolCalls || finishEv.toolCalls.length === 0;
if (hasTools && rescueAttempt < rescueMax && isStopOrNull && hasNoToolCalls && sawReasoning && !sawText) {
rescueAttempt++;
log.warn(`DEVIN_CONNECT: thinking-only completion (reasoning-only, finish=${finishEv?.reason ?? 'null'}) — rescue retry ${rescueAttempt}/${rescueMax} (nudge appended)`);
const backoff = retryOnEmptyBaseMs(env) * rescueAttempt;
if (backoff && !(await budgetedSleep(backoff, budget))) {
throw budgetExceededError(`request budget exhausted during DEVIN_CONNECT rescue backoff (${budget.describe()})`);
}
// Rebuild from the ORIGINAL request every rescue: `params` is never mutated (each
// rescue constructs a fresh attemptParams below), so it always holds the client's
// messages. Building from attemptParams instead accumulated — previous rescues'
// nudges are role:'user' so the filter keeps them, and with a capped digest each
// stale nudge is ~2.1KB at the 2000-char default: five rescues stacked ~10.4KB
// of old nudges into the upstream call, each quoting reasoning the model already
// moved past.
// One rescue = exactly one fresh nudge.
const origMsgs = params?.messages || [];
const filteredMsgs = origMsgs.filter((msg) => {
if (msg.role !== 'assistant') return true;
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length) return true;
if (msg.reasoning_signature && String(msg.reasoning_content || '').length > 0) return true;
return messageText(msg.content).trim() !== '';
});
let nudgeText = 'Stop reasoning. Emit the tool call markup now.';
const digest = reasoningDigestMaxChars > 0 ? sawReasoningText.slice(-reasoningDigestMaxChars).trim() : '';
if (digest) {
nudgeText = `Your previous reasoning ended with: """${digest}"""\nStop reasoning. Emit the tool call markup now.`;
}
const rescued = [
...filteredMsgs,
{ role: 'user', content: nudgeText },
];
attemptParams = { ...attemptParams, messages: rescued };
// The abandoned attempt's provisional reasoning dies with this loop
// iteration (`pendingReasoning` is declared inside it): it was never sent
// (the buffer is only released at commit), so the client, the returned
// result, the cache and session continuity all stay on the replacement
// attempt. The attempt_reset below still tells the consumer to clear the
// other accumulators (content/tool calls/billing) for the same reason.
// Tell the consumer to discard what it accumulated: the next attempt REPLACES
// this one, it does not continue it. Without this every rescue attempt's
// reasoning was concatenated into a single answer — measured
// "PASS1. PASS2. PASS3. " for three attempts, i.e. the user was shown all
// three tries glued together. The outer retry loops in toChatCompletion /
// streamChatCompletion reset their accumulators per THEIR OWN attempt, but the
// rescue loop lives in here, so they never saw an attempt boundary.
yield { type: 'attempt_reset' };
continue;
}
if (emptyAttempt < max && isEmptyCompletion(finishEv, sawContent)) {
emptyAttempt++;
log.warn(`DEVIN_CONNECT: empty completion (finish=${finishEv.reason ?? 'null'}, completion_tokens=${finishEv.usage?.completion_tokens ?? 'n/a'}) — retry ${emptyAttempt}/${max}`);
// Backoff still grows with the number of EMPTY retries (base×1, base×2, …), which is
// what it did before when `attempt` happened to equal that count. Keying it to the
// shared counter instead would make an unrelated rescue lengthen the next empty wait.
const backoff = retryOnEmptyBaseMs(env) * emptyAttempt;
if (backoff && !(await budgetedSleep(backoff, budget))) {
throw budgetExceededError(`request budget exhausted during DEVIN_CONNECT empty-retry backoff (${budget.describe()})`);
}
continue;
}
if (weak && finishEv && isEmptyCompletion(finishEv, sawContent)) {
log.warn(`DEVIN_CONNECT: weak model ${params.model} empty completion (finish=${finishEv.reason ?? 'null'}) — NOT retrying (deterministic, retry would amplify rate limit)`);
}
// No rescue: this is the committed attempt. Release any reasoning still held
// while a rescue was possible, before its terminal finish event.
for (const text of pendingReasoning) yield { type: 'reasoning', text };
pendingReasoning = [];
if (pendingSignature) { yield { type: 'signature', reasoning_signature: pendingSignature }; pendingSignature = ''; }
if (finishEv) yield finishEv;
return;
}
}
// Sleep that can never outlive the request budget. Returns false when the
// budget has no time left — including when it cannot cover the full backoff —
// so the caller stops instead of starting an attempt that the route deadline
// would kill mid-flight.
async function budgetedSleep(ms, budget) {
if (!ms) return true;
if (!budget) {
await new Promise((r) => setTimeout(r, ms));
return true;
}
const left = budget.remainingMs();
// A backoff the budget cannot fully honour means there is no room left for
// the retry it was pacing. Sleeping the clamped remainder and then re-issuing
// starts the attempt with a sliver of time, and the deadline kills it
// mid-flight — the v3.9.57 CI flake (run 39861008441). The final
// `remainingMs() > 0` check cannot catch that: it is the same condition
// budgetedSleep already returned, and canStartAttempt() adds only the turn
// cap. Stop here; each caller surfaces its own error.
if (left < ms) return false;
await new Promise((r) => setTimeout(r, ms));
return budget.remainingMs() > 0;
}
const OBJECT_COMPLETION = 'chat.completion';
const OBJECT_CHUNK = 'chat.completion.chunk';
function newId() {
return `chatcmpl-${randomUUID().replace(/-/g, '')}`;
}
function nowSeconds() {
return Math.floor(Date.now() / 1000);
}
/**
* Collect a full DEVIN_CONNECT completion and shape it as a non-streaming
* `chat.completion` object.
*
* @param {object} params forwarded to streamChat (messages, model, token, …)
* @param {object} [opts]
* @param {string} [opts.id] response id (default chatcmpl-…)
* @param {number} [opts.created] unix seconds (default now)
* @param {string} [opts.displayModel] model name echoed back to the client
* @param {boolean} [opts.emulateTools] when true, parse <tool_call> markup out of
* the buffered answer and surface OpenAI
* tool_calls (text-emulation, swe-1.6 etc).
* @returns {Promise<{status:number, body:object, stop:string|null}>}
*/
export async function toChatCompletion(params, opts = {}) {
const { id = newId(), created = nowSeconds(), displayModel, maxRetries = 1, retryBaseMs = 400, emulateTools = false, stop = null, clineCompat = false, stopCarrier = false, budget = null } = opts;
const model = displayModel || params.model;
// Non-stream path buffers the whole answer, so a transient failure (network
// blip, 5xx, rate limit) can be retried cleanly — a discarded partial buffer
// never duplicates tokens. Terminal errors (MODEL_BLOCKED / UNAUTHORIZED)
// are not retryable and throw straight through for the handler to map.
let content = '';
let reasoning = '';
let reasoningSignature = '';
let signedSegment = false;
let multipleSignedSegments = false;
let finishReason = 'stop';
// The upstream ended the stream without ever naming a stop reason (devin-connect
// raises `reason:'error'` + `unterminated:true` for a delivered payload with no
// #5). Remembered separately because the tool-call arms below overwrite
// finishReason with 'tool_calls' whenever a call exists — which would report a
// turn whose arguments were never validated as a clean, executable finish.
let finishUnterminated = false;
let usage = null;
// Per-request billing detail (credit / ACU cost) when the operator has calibrated
// DEVIN_CONNECT_BILLING_TAGS. devin-connect.js has always yielded this on the
// finish event; it stopped here because the adapter never forwarded it, so the
// dashboard's lifetime-credit column could only ever render 0.
let billing = null;
// Native tool calls (DEVIN_CONNECT_TOOL_CALL_TAGS calibrated) ride the terminal
// finish event as ev.toolCalls (devin-connect.js:927). Null/empty on free tier
// and un-calibrated deployments, where prompt emulation owns tool calls.
let nativeToolCalls = [];
for (let attempt = 0; ; attempt++) {
try {
content = ''; reasoning = ''; reasoningSignature = ''; signedSegment = false; multipleSignedSegments = false; finishReason = 'stop'; finishUnterminated = false; usage = null; nativeToolCalls = [];
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 = ''; reasoningSignature = ''; signedSegment = false; multipleSignedSegments = false; nativeToolCalls = []; finishUnterminated = false; billing = null; continue; }
if (ev.type === 'content') content += ev.text;
else if (ev.type === 'reasoning') {
if (signedSegment) multipleSignedSegments = true;
reasoning += ev.text;
}
// Thinking signature (opt-in decode, DEVIN_CONNECT_SIGNATURE_TAG). The
// per-frame events are increments; the finish event's total is not used
// because a rescue attempt_reset must discard the abandoned increments.
else if (ev.type === 'signature') {
reasoningSignature += ev.reasoning_signature || '';
signedSegment = true;
}
else if (ev.type === 'finish') {
if (ev.reason) finishReason = ev.reason;
if (ev.unterminated === true) finishUnterminated = true;
if (ev.usage) usage = ev.usage;
if (ev.billing) billing = ev.billing;
if (ev.toolCalls && ev.toolCalls.length) nativeToolCalls = ev.toolCalls;
}
}
break;
} catch (err) {
if (!isRetryable(err) || attempt >= maxRetries) throw err;
// The inner generator already consumes a turn per upstream call; when
// the route budget has no turn/time left, re-issuing here would only be
// refused. Surface the real upstream error instead of a synthetic one.
if (budget && !budget.canStartAttempt()) throw err;
const backoff = retryBaseMs * 2 ** attempt;
log.warn(`DEVIN_CONNECT: retryable error (${err.code || err.message}); retry ${attempt + 1}/${maxRetries} in ${backoff}ms`);
if (!(await budgetedSleep(backoff, budget))) throw err;
// Re-read the gate after the wait: the pre-sleep check is a snapshot, and
// the retry must be admitted by the budget as it stands now. (The deadline
// sliver itself is budgetedSleep()'s job — it refuses a backoff the budget
// cannot cover.) The real upstream error is what surfaces.
if (budget && !budget.canStartAttempt()) throw err;
}
}
// Tool calls come from one of two sources, never both. Native decode wins:
// when DEVIN_CONNECT_TOOL_CALL_TAGS is calibrated, streamChat surfaces real
// ChatToolCall structs ({id, name, arguments}) on the finish event, so the
// text never carries <tool_call> markup to parse. Otherwise (free tier /
// un-calibrated) the connect models have no native function-calling slot, so
// tool defs were injected into the prompt (normalizeMessagesForCascade) and
// the model answers with <tool_call>…</tool_call> markup we pull back out,
// mirroring the Cascade non-stream path (handlers/chat.js buildToolCalls).
//
// SEED-C3c: the emulated parse runs BEFORE the stop gate, never after it. The
// stop sequence fences the model's PROSE; a complete, declared, allowlisted
// tool call carries opaque structured arguments. Gating the raw text first cut
// inside that JSON and handed non-stream clients a truncated `<tool_call>`
// fragment with no tool at all, while the stream path (which parses before its
// prose gate, see sendContent below) returned the real call — the same request
// answered two different ways depending on `stream`. Native structured calls
// are opaque for the same reason.
//
// This does NOT decide mixed prose/tool ordering (an END that appears before a
// later call, split-chunk order, salvage parity): only the accepted, declared,
// allowlisted structured call is exempt from the cut.
let stopHit = false;
let matchedStop = null;
let toolCalls = [];
// The upstream's OWN terminal verdict (enum 13 → 'error'), captured before the
// tool-call arms below, which set 'tool_calls' unconditionally whenever a call
// was decoded. An error turn that happened to emit a decodable call is still an
// error — the call's arguments are as unverified as the rest of the turn.
const terminalError = finishReason === 'error';
if (nativeToolCalls.length) {
// arguments is the raw JSON string off the wire (decodeToolCalls); map it to
// the same shape parseToolCallsFromText produces so the message builder below
// is source-agnostic.
toolCalls = nativeToolCalls.map((tc) => ({
id: tc.id, name: tc.name, argumentsJson: tc.arguments,
}));
finishReason = 'tool_calls';
} else if (emulateTools) {
const parsed = parseToolCallsFromText(content, {
modelKey: params.model, provider: null, route: 'devin_connect',
});
if (parsed.toolCalls.length) {
content = parsed.text;
toolCalls = filterToolCallsByAllowlist(parsed.toolCalls, params.tools || []); // M2 ToolGuard parity
}
}
// proto-openai-03: enforce the client's `stop` locally (the Devin wire has no
// native stop field). Truncate at the earliest stop-sequence hit and report
// finish_reason:'stop'. The cut applies to the prose that is left after the
// structured-call parse above; tool arguments are never matched against it.
{
const stopped = applyStop(content, stop);
if (stopped.hit) { content = stopped.text; matchedStop = stopped.stop; stopHit = true; }
}
if (stopHit && !toolCalls.length) finishReason = 'stop';
// Fallback promotion: promote reasoning to content when no tool calls and no content exist
// so plain prompts never return an empty visible answer to clients.
//
// MOVED, not copied. Emitting the same text as both `content` and
// `reasoning_content` made every client that renders reasoning show the answer
// twice — and on the Anthropic route it produced a `thinking` block and a `text`
// block with byte-identical content, which is exactly what the reporter's own
// client (kimi CLI) displays. Nothing is lost by dropping the duplicate: the
// text is still delivered, just once, in the field the client will actually
// render as the answer.
// The promoted text must go through the SAME tool-call extraction the content
// path just ran. The rescue nudge literally asks the model to "Emit the tool call
// markup now", so a model that complies but keeps writing into the reasoning
// channel produces exactly this shape — and promoting it unparsed delivered raw
// `<tool_call>` XML to the client as the visible answer with finish_reason='stop'.
// The agent loop does not advance and the user sees markup, which is worse than
// the empty turn the promotion exists to prevent.
let promotedReasoning = !toolCalls.length && !content && !!reasoning && !stopHit;
if (promotedReasoning) {
if (emulateTools && !stopHit) {
const promotedParse = parseToolCallsFromText(reasoning, {
modelKey: params.model, provider: null, route: 'devin_connect',
});
if (promotedParse.toolCalls.length) {
// M2 ToolGuard parity: only keep calls the client declared
const allowedCalls = filterToolCallsByAllowlist(promotedParse.toolCalls, params.tools || []);
// It was a tool call all along. Deliver it as one; keep any surrounding prose
// as content, and leave reasoning_content in place since nothing was moved
// out of it into the answer.
toolCalls = allowedCalls;
content = promotedParse.text;
finishReason = 'tool_calls';
promotedReasoning = false;
} else {
content = reasoning;
}
} else {
content = reasoning;
}
}
// Promotion creates visible prose after the first gate. Gate it at this final boundary.
if (promotedReasoning) {
const stopped = applyStop(content, stop);
content = stopped.text;
if (stopped.hit) { matchedStop = stopped.stop; stopHit = true; }
if (stopHit && !toolCalls.length) finishReason = 'stop';
}
// OpenAI convention: content is a string (may be empty), never undefined.
const message = { role: 'assistant', content: content || '' };
if (reasoning && !promotedReasoning) message.reasoning_content = reasoning;
// The signature seals exactly this reasoning text; promoted reasoning is no
// longer a thinking block, so a signature beside it would seal nothing.
if (reasoning && !promotedReasoning && reasoningSignature && !multipleSignedSegments) message.reasoning_signature = reasoningSignature;
if (toolCalls.length) {
message.tool_calls = toolCalls.map((tc, i) => ({
id: toClientToolCallId(tc.id, `call_${i}_${Date.now().toString(36)}`),
type: 'function',
function: { name: tc.name || 'unknown', arguments: compatArgs(tc.argumentsJson || tc.arguments, clineCompat) },
}));
// content is null when the turn is a tool call (the inline text is usually
// a hallucinated preview the caller shouldn't show).
message.content = null;
finishReason = 'tool_calls';
}
// Budget honesty (live finding: gemini-3-6-flash-medium). Thinking is counted
// in completion_tokens but the upstream does not bound it by max_tokens, so a
// turn can overrun the caller's cap and still claim a clean 'stop'. Report
// 'length' when the upstream's own count reached or passed the requested cap.
// NOT a local truncation: every delta has already been delivered above, and
// the exact count only exists on the terminal frame — see applyOutputCap.
//
// A turn cut by the CLIENT's stop sequence keeps 'stop': the caller asked for
// that terminal cause and the Messages route carries it as `stop_sequence`
// (the carrier below is gated on 'stop'). Tool calls / content_filter are
// stronger states and applyOutputCap leaves them alone.
//
// A turn the upstream ended in ERROR (enum 13) stays an error even when a
// decodable tool call arrived with it — the tool-call arms above would otherwise
// launder a failed turn into a clean, executable `tool_calls` finish. Same for a
// turn that never named a stop reason at all. Placed after EVERY tool-call arm so
// a future one cannot slip past it, and before applyOutputCap (which only
// upgrades 'stop').
if (terminalError) finishReason = 'error';
else if (finishUnterminated && finishReason === 'tool_calls') finishReason = 'error';
if (!stopHit) finishReason = applyOutputCap(finishReason, usage, params.completion?.maxTokens);
// #236 log-only detector — AFTER emulation and the stop gate, on the text and
// calls the client actually receives. `stopHit` means a local stop sequence
// cut the answer, which is not a model stall.
if (!stopHit) {
reportProseToolIntent({
params,
text: content,
finishEv: { reason: finishReason, toolCalls },
reasoning,
});
}
const choice = { index: 0, message, finish_reason: finishReason };
// SEED-C3a carrier. The internal Anthropic Messages route needs to know WHICH
// stop sequence truncated this answer (it echoes it as `stop_sequence`), and
// the matched bytes are gone from `content`, so the cause has to ride along.
// Serialisable plain field, not a Symbol: the streaming twin travels through
// JSON.stringify into the messages translator's SSE parser. Gated on the
// explicit route opt-in (chat.js sets `stopCarrier` only for
// trusted Messages context) so a direct OpenAI client sees the public shape
// byte-for-byte. Only a genuine local stop carries it — a tool/length/refusal
// finish cannot be upgraded by a stray value.
if (stopCarrier && matchedStop && finishReason === 'stop') choice._windsurf_stop_sequence = matchedStop;
const body = {
id,
object: OBJECT_COMPLETION,
created,
model,
system_fingerprint: systemFingerprint(model),
choices: [choice],
};
if (usage) body.usage = usage;
// Private field: per-request credit/ACU cost, absent unless the billing tags are
// calibrated. Under the _windsurf prefix so it can't be mistaken for an OpenAI
// field by a downstream relay that metering reads.
if (billing) body._windsurf_billing = billing;
return { status: 200, body, stop: matchedStop };
}
/**
* Stream a DEVIN_CONNECT completion as OpenAI `chat.completion.chunk` SSE
* frames. `send` is the SSE writer used in handlers/chat.js — a function taking
* a JS object that it JSON-encodes onto the `data:` line. This helper does NOT
* write `data: [DONE]` or close the response; the caller owns the socket
* lifecycle (heartbeat, unregister, res.end) exactly as the Cascade path does.
*
* Emission order mirrors the Cascade stream:
* 1. role-priming chunk (delta {role, content:''}) — DEFERRED until the first
* real delta (or the finish tail) so a pre-open transient/dead-token error
* leaves the caller's first-connect recovery armed (see `prime` below)
* 2. reasoning_content deltas — provisional while a rescue could still replace
* the attempt; released only when the attempt is committed (first text
* delta or terminal finish), so an abandoned attempt never reaches the wire
* 3. content deltas as they arrive
* 4. finish chunk (delta {}, finish_reason)
* 5. usage-only chunk (choices [], usage) when usage is known
*
* @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, opts = {}) {
const { id = newId(), created = nowSeconds(), displayModel, emulateTools = false, includeUsage = false, stop = null, clineCompat = false, stopCarrier = false, maxRetries = 1, retryBaseMs = 400, budget = null } = opts;
const model = displayModel || params.model;
const base = { id, object: OBJECT_CHUNK, created, model, system_fingerprint: systemFingerprint(model) };
// 1. Role-priming chunk. This USED to fire eagerly here, before streamChat
// opened the upstream — but the very first send() flips `emitted=true` in
// the caller (handlers/chat.js), which disarms the handler's !emitted-gated
// account recovery branches (re-login / failover). A dead token — which the
// non-stream path re-logs-in / fails over — then surfaced as a hard client
// error on the stream path. So we DEFER the prime behind `primed` until the
// first REAL delta (content / reasoning / tool_call) actually arrives, i.e.
// until the upstream has demonstrably opened. `primed` also gates this
// adapter's own one-shot transport retry (nothing emitted ⇒ safe replay).
// The empty / immediate-finish path primes from the finish tail below, so a
// legitimately empty response is still a well-formed OpenAI stream
// (role → finish → optional usage). Operators who relied on the eager role
// chunk can restore it with DEVIN_CONNECT_EAGER_PRIME=1.
let primed = false;
// V-08: `send` is the handler's bounded writer and returns a promise once the
// socket reports backpressure. Awaiting it here (and in every emitter below)
// is what lets a slow reader hold this producer instead of buying unbounded
// upstream work. A synchronous consumer returns undefined, so `await` is a
// no-op on the direct-Chat wiring that still passes a plain function.
const prime = async () => {
if (primed) return;
primed = true;
await send({ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] });
};
if (String(process.env.DEVIN_CONNECT_EAGER_PRIME || '') === '1') await prime();
let content = '';
let reasoning = '';
let finishReason = 'stop';
// Same guard as the non-stream twin: the tool-call arms below force
// finish_reason='tool_calls' whenever a call was emitted, which would label a
// turn the upstream never terminated as a clean, executable finish.
let finishUnterminated = false;
let usage = null;
// Exactly what the client received as visible content, after tool-markup
// stripping and the stop gate. `content` is the RAW upstream accumulator and
// is used for tool parsing/session continuity; caching must use `delivered`
// or a filtered/echoed <tool_call> block becomes the visible cached answer.
let delivered = '';
// Same billing passthrough as the non-stream path.
let streamBilling = null;
// Native tool calls (DEVIN_CONNECT_TOOL_CALL_TAGS calibrated) ride the terminal
// finish event, not the content stream — captured here, emitted after the loop.
let nativeToolCalls = [];
// proto-openai-03: stream-side stop enforcement. The gate holds back a short
// tail so a stop sequence straddling two content chunks is still caught; on a
// hit we emit the safe prefix, flip finish_reason:'stop', and stop the stream.
// `let` because a pre-emit transport retry must start from a fresh gate.
let stopGate = new StopSequenceGate(stop);
let stopHit = false;
let matchedStop = null;
// Emit content through the stop gate. Returns true when the stream should end.
const sendContent = async (text) => {
if (!text) return false;
if (!stopGate.active) {
await send({ ...base, choices: [{ index: 0, delta: { content: text }, finish_reason: null }] });
delivered += text;
return false;
}
const { emit, hit } = stopGate.push(text);
if (emit) {
await send({ ...base, choices: [{ index: 0, delta: { content: emit }, finish_reason: null }] });
delivered += emit;
}
if (hit) { finishReason = 'stop'; stopHit = true; matchedStop = stopGate.matched; }
return hit;
};
// Tool emulation: run content deltas through the same streaming parser the
// Cascade path uses. It strips <tool_call> markup from the text deltas and
// surfaces fully-closed calls; we emit each as an OpenAI tool_calls delta
// (whole arguments at once, keyed by index — matching Cascade, not
// token-by-token argument streaming). finish_reason flips to tool_calls.
const toolParser = emulateTools
? new ToolCallStreamParser({ modelKey: params.model, provider: null, route: 'devin_connect' })
: null;
const collectedToolCalls = [];
const emitToolCalls = async (calls, filter = true) => {
// ToolGuard: same allowlist filter the chat main path applies — the
// connect stream path used to emit any EMULATED call without checking it
// against the client's declared tools[] (audit blind spot). Native calls
// (structured ev.toolCalls from upstream) are trusted and pass through.
if (filter) calls = filterToolCallsByAllowlist(calls || [], params.tools || []);
for (const tc of calls || []) {
await prime(); // a tool_call is a real delta — open the message before it
const idx = collectedToolCalls.length;
// Normalize once, before the call is recorded: the emitted delta AND the
// returned toolCalls (session-continuity commit, cache) must agree with
// what the client saw, or the next turn's echoed id no longer matches.
const emitted = {
...tc,
id: toClientToolCallId(tc.id, `call_${idx}_${Date.now().toString(36)}`),
};
collectedToolCalls.push(emitted);
await send({ ...base, choices: [{ index: 0, delta: {
tool_calls: [{
index: idx,
id: emitted.id,
type: 'function',
function: { name: emitted.name || 'unknown', arguments: compatArgs(emitted.argumentsJson, clineCompat) },
}],
}, finish_reason: null }] });
}
};
// Q5 ownership: the ADAPTER owns transport retries. One bounded retry for a
// retryable fault (5xx / ECONNRESET / server unavailable) is safe ONLY while
// nothing has crossed the wire — `primed` is flipped by the first real delta
// (and by the eager-prime opt-in), so a partial answer is never replayed.
// Account failover stays in the handler.
for (let attempt = 0; ; attempt++) {
try {
for await (const ev of streamChatWithEmptyRetry(params, undefined, opts)) {
// A rescue attempt REPLACES the previous one. The generator holds
// reasoning while an attempt can still be rescued, so no abandoned
// reasoning delta ever reaches this consumer; the accumulators are
// reset anyway so `content`, `delivered`, tool calls and billing can
// never carry a discarded attempt into the returned result, the cache
// or session continuity. (`reasoning` is reset for the same contract,
// even though the generator already guarantees it is committed-only.)
if (ev.type === 'attempt_reset') {
reasoning = '';
content = '';
delivered = '';
collectedToolCalls.length = 0;
nativeToolCalls = [];
continue;
}
if (ev.type === 'reasoning') {
await prime(); // first real delta: emit the deferred role chunk first
reasoning += ev.text;
await send({ ...base, choices: [{ index: 0, delta: { reasoning_content: ev.text }, finish_reason: null }] });
} else if (ev.type === 'signature') {
// Opt-in decode (DEVIN_CONNECT_SIGNATURE_TAG). Upstream sends it after
// the last reasoning frame and before content, so messages.js can close
// the thinking block with a signature_delta. The generator releases it
// only with a committed attempt, so it always seals exactly `reasoning`.
if (reasoning && ev.reasoning_signature) {
await send({ ...base, choices: [{ index: 0, delta: { reasoning_signature: ev.reasoning_signature }, finish_reason: null }] });
}
} else if (ev.type === 'content') {
await prime(); // first real delta: emit the deferred role chunk first
content += ev.text;
if (toolParser) {
const { text, toolCalls } = toolParser.feed(ev.text);
await emitToolCalls(toolCalls);
if (await sendContent(text)) break;
} else {
if (await sendContent(ev.text)) break;
}
} else if (ev.type === 'finish') {
if (ev.reason) finishReason = ev.reason;
if (ev.unterminated === true) finishUnterminated = true;
if (ev.usage) usage = ev.usage;
if (ev.billing) streamBilling = ev.billing;
if (ev.toolCalls && ev.toolCalls.length) nativeToolCalls = ev.toolCalls;
}
}
break;
} catch (err) {
if (primed || attempt >= maxRetries || !isRetryable(err)) throw err;
// A client abort during the previous attempt (or during the backoff
// below) must not start a fresh upstream call for a caller that is gone.
if (params.signal?.aborted) throw err;
// The route budget owns the total attempt count: re-issuing with no
// turn/time left would only be refused by the next upstream call.
if (budget && !budget.canStartAttempt()) throw err;
const backoff = retryBaseMs * 2 ** attempt;
log.warn(`DEVIN_CONNECT: stream retryable error (${err.code || err.status || err.message}); retry ${attempt + 1}/${maxRetries} in ${backoff}ms`);
bumpConnect('transient_replays');
if (!(await budgetedSleep(backoff, budget))) throw err;
// Same post-wait gate as the non-stream loop: the budget's state after the
// sleep, not the pre-sleep snapshot, decides whether the stream may be
// re-issued. The real upstream error is what surfaces.
if (budget && !budget.canStartAttempt()) throw err;
if (params.signal?.aborted) throw err;
// Nothing was emitted, so a clean restart cannot duplicate output.
content = ''; reasoning = ''; finishReason = 'stop'; finishUnterminated = false; usage = null;
streamBilling = null; nativeToolCalls = []; delivered = '';
collectedToolCalls.length = 0;
stopGate = new StopSequenceGate(stop);
stopHit = false; matchedStop = null;
}
}
// The upstream's OWN terminal verdict (enum 13 → 'error'), captured before the
// tool-call arms below. Those arms set 'tool_calls' unconditionally, which turned
// an upstream ERROR that happened to carry a decodable call into a clean,
// executable finish.
const terminalError = finishReason === 'error';
// Drain any tool_call still buffered at end-of-stream, plus the trailing text.
// Skip when a stop sequence already ended the stream (stopHit) — the tail after
// the stop must not leak out.
if (toolParser && !stopHit) {
const { text, toolCalls } = toolParser.flush();
await emitToolCalls(toolCalls);
await sendContent(text);
if (collectedToolCalls.length) finishReason = 'tool_calls';
}
// Native tool calls win over text emulation (the two are mutually exclusive:
// when native decode is calibrated the text carries no <tool_call> markup).
// Only emit native if emulation produced nothing, so a call is never counted
// twice. Native arrives whole on the finish event, so it's emitted here rather
// than inline — same wire shape as the emulated deltas above.
if (nativeToolCalls.length && !collectedToolCalls.length) {
await emitToolCalls(nativeToolCalls.map((tc) => ({
id: tc.id, name: tc.name, argumentsJson: tc.arguments,
})), false); // native calls are trusted (structured upstream data), no allowlist filter
finishReason = 'tool_calls';
}
// Fallback promotion: promote reasoning to content when no tool calls and no content exist
// so plain prompts never return an empty visible answer to clients.
//
// KNOWN AND ACCEPTED COST, unlike the non-stream path above where the duplicate
// is simply removed. Here the reasoning deltas have ALREADY gone out on the wire
// as `reasoning_content`, and a stream cannot retract what it emitted — so a
// client that renders reasoning will show this text twice. The alternatives were
// weighed and are worse:
//
// - Buffer reasoning and decide at end-of-stream: kills the reason streaming
// exists (watching the model think in real time) for EVERY turn, to spare a
// duplicate on the minority that end up reasoning-only.
// - Emit a short marker instead of the text: a strict client
// (kimi CLI: "response containing only thinking content") is satisfied by
// any text, but the user then loses the actual answer — the reasoning IS the
// answer on a plain prompt, which is the whole case this branch serves.
// - Don't promote on the stream path at all: leaves the original bug in place
// for streaming clients, which is the common configuration.
//
// So the duplicate is the price of rescuing a strict client mid-stream. It is
// recorded here rather than left to look like an oversight; if a future change
// can dedupe at the translator layer (messages.js already knows whether the
// text block equals the thinking block), that is the place to do it.
if (!content && !collectedToolCalls.length && !nativeToolCalls.length && reasoning && !stopHit) {
// Same extraction the content path uses, for the same reason as the non-stream
// path: the rescue nudge asks for tool markup, so a model that complies while
// still writing into the reasoning channel would otherwise have raw
// `<tool_call>` XML streamed to it as the visible answer.
const promotedParse = emulateTools
? parseToolCallsFromText(reasoning, { modelKey: params.model, provider: null, route: 'devin_connect' })
: { toolCalls: [], text: reasoning };
if (promotedParse.toolCalls.length) {
if (promotedParse.text) await sendContent(promotedParse.text);
await emitToolCalls(promotedParse.toolCalls.map((tc) => ({
id: tc.id, name: tc.name, argumentsJson: tc.argumentsJson ?? tc.arguments,
})));
content = promotedParse.text;
finishReason = 'tool_calls';
} else {
await sendContent(reasoning);
content = reasoning;
}
}
// proto-openai-03: release the gate's held tail (the last few chars it was
// withholding in case they started a stop sequence). No-op after a hit.
if (!stopHit && stopGate.active) {
const tail = stopGate.flush();
if (tail) {
await send({ ...base, choices: [{ index: 0, delta: { content: tail }, finish_reason: null }] });
delivered += tail;
}
}
// Budget honesty — same rule and same reasons as the non-stream twin above.
// Applied only after the gate's held tail has been released, so the terminal
// frame still follows every delivered delta: the answer is reported as over
// budget, never retracted or cut mid-chunk. A local stop sequence keeps
// 'stop' (the caller asked for that cause; the carrier below depends on it).
// A turn the upstream ended in ERROR (enum 13) stays an error even when a
// decodable tool call arrived with it; a turn that never named a stop reason is
// not a clean tool-call finish either. The calls were already emitted as deltas
// (a stream cannot retract them), so the terminal frame is the only place that
// can say "these arguments are unverified". Without this, every tool-call arm
// above (`toolParser.flush`, native #6, promoted reasoning) forced 'tool_calls'.
if (terminalError) finishReason = 'error';
else if (finishUnterminated && finishReason === 'tool_calls') finishReason = 'error';
if (!stopHit) finishReason = applyOutputCap(finishReason, usage, params.completion?.maxTokens);
// #236 log-only detector — AFTER emulation and the stop gate, on what the
// client actually received. A delivered call (emulated or native) sets
// finish_reason='tool_calls', so it can never be counted as prose intent;
// `stopHit` is a local cut, not a stall.
if (!stopHit) {
reportProseToolIntent({
params,
text: delivered,
finishEv: { reason: finishReason, toolCalls: collectedToolCalls },
reasoning,
});
}
// 4. Terminal finish chunk. Reaching here means streamChat drained cleanly
// (the upstream opened and completed); if it had thrown before any delta,
// the exception would have propagated with `primed` — and therefore the
// caller's `emitted` — still false, leaving first-connect recovery armed.
// An empty / immediate-finish response yielded no delta to prime from, so
// prime here to keep the stream well-formed: role → finish → optional usage.
await prime();
const finishChoice = { index: 0, delta: {}, finish_reason: finishReason };
// SEED-C3a carrier — see the non-stream twin above. This frame is what the
// internal Messages translator sees (chat.js serialises it onto the `data:`
// line, messages.js parses it back), so the field must survive JSON round-trip
// and is emitted only on the route opt-in and only for a genuine local stop.
if (stopCarrier && matchedStop && finishReason === 'stop') finishChoice._windsurf_stop_sequence = matchedStop;
await send({ ...base, choices: [finishChoice] });
// 5. Usage-only chunk (OpenAI streams usage in a trailing choices:[] frame).
// O1: only when the caller opted in via stream_options.include_usage;
// OpenAI omits this frame by default.
if (usage && includeUsage) {
await send({ ...base, choices: [], usage });
}
return { content, delivered, reasoning, finish_reason: finishReason, usage, billing: streamBilling, toolCalls: collectedToolCalls, stop: matchedStop };
}
export const __testing = {
newId, nowSeconds, OBJECT_COMPLETION, OBJECT_CHUNK,
isEmptyCompletion, streamChatWithEmptyRetry,
retryOnEmptyEnabled, retryOnEmptyMax,
};