') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(agent): stream LLM completions to avoid gateway idle-timeout by sebastianbraun25 · Pull Request #236 · VectifyAI/OpenKB · GitHub
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
160 changes: 156 additions & 4 deletions openkb/agent/compiler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -397,6 +397,134 @@ class TruncatedResponseError(Exception):
treat truncation as a failure (so a partial page is skipped, not written)."""


def _merge_stream_chunks(chunks: list, messages: list[dict]):
"""Merge streamed LLM chunks back into a single, non-streaming response.

Genuine LiteLLM stream chunks only ever carry a ``.delta`` (never a
``.message``), so a real multi-chunk stream is merged via LiteLLM's own
:func:`litellm.stream_chunk_builder`. A single chunk that already looks
like a complete, non-streaming ``ModelResponse`` (exposing ``.message``)
is used as-is — there's nothing left to merge, and it lets test doubles
fake a one-shot response without simulating LiteLLM's internal delta
format.
"""
choices = getattr(chunks[0], "choices", None) or []
if len(chunks) == 1 and choices and hasattr(choices[0], "message"):
return chunks[0]
return litellm.stream_chunk_builder(chunks, messages=messages)


def _log_stream_start(step_name: str, t0: float, first_chunk_t: float) -> None:
"""Debug-log the time-to-first-chunk (TTFT) once a stream's first chunk arrives.

Marks the start of a "chunk phase" in the log. The counterpart is
:func:`_log_stream_end` (clean finish) or :func:`_log_stream_interrupted`
(mid-stream failure) — together these replace a debug line per chunk
(which used to drown out the rest of the log on a long response, e.g.
hundreds of lines for one LLM call) with exactly one line at the start
and exactly one more at the end/interruption.
"""
logger.debug(
"LLM stream started [%s]: first chunk after %.2fs",
step_name,
first_chunk_t - t0,
)


def _log_stream_end(step_name: str, chunk_count: int, t0: float, last_chunk_t: float) -> None:
"""Debug-log a stream's clean completion: total chunk count and elapsed time."""
logger.debug(
"LLM stream finished [%s]: %d chunk(s), last chunk after %.2fs total",
step_name,
chunk_count,
last_chunk_t - t0,
)


def _log_stream_interrupted(
step_name: str, chunk_count: int, t0: float, last_chunk_t: float
) -> None:
"""Debug-log a stream that raised mid-iteration, right before it is re-raised.

``chunk_count`` is how many chunks were successfully received before the
failure (0 if the very first chunk never arrived). The exception itself
(with traceback) is attached via ``exc_info=True`` so the failure and the
chunk-phase summary land in a single log record.
"""
now = time.time()
if chunk_count == 0:
logger.debug(
"LLM stream [%s] interrupted unexpectedly before any chunk arrived (%.2fs total)",
step_name,
now - t0,
exc_info=True,
)
return
logger.debug(
"LLM stream [%s] interrupted unexpectedly after chunk %d "
"(last chunk after %.2fs, failure after %.2fs total)",
step_name,
chunk_count,
last_chunk_t - t0,
now - t0,
exc_info=True,
)


def _consume_stream(stream, step_name: str, t0: float) -> list:
"""Collect a sync LiteLLM stream into a list, debug-logging the chunk phase.

Logs exactly one line when the first chunk arrives (time-to-first-token)
and exactly one more line when the stream ends — either
:func:`_log_stream_end` on a clean finish or :func:`_log_stream_interrupted`
if it raises mid-iteration. A mid-stream exception (e.g. the gateway
idle-timeout firing) propagates after being logged, so callers still see
a complete failure — no partial buffer is ever returned.
"""
if not logger.isEnabledFor(logging.DEBUG):
return list(stream)

chunks: list = []
last_t = t0
try:
for chunk in stream:
now = time.time()
if not chunks:
_log_stream_start(step_name, t0, now)
chunks.append(chunk)
last_t = now
except Exception:
_log_stream_interrupted(step_name, len(chunks), t0, last_t)
raise
_log_stream_end(step_name, len(chunks), t0, last_t)
return chunks


async def _consume_stream_async(stream, step_name: str, t0: float) -> list:
"""Collect an async LiteLLM stream into a list, debug-logging the chunk phase.

Mirrors :func:`_consume_stream`, including the start/end-or-interrupted
logging and the no-partial-buffer invariant on failure.
"""
if not logger.isEnabledFor(logging.DEBUG):
return [chunk async for chunk in stream]

chunks: list = []
last_t = t0
try:
async for chunk in stream:
now = time.time()
if not chunks:
_log_stream_start(step_name, t0, now)
chunks.append(chunk)
last_t = now
except Exception:
_log_stream_interrupted(step_name, len(chunks), t0, last_t)
raise
_log_stream_end(step_name, len(chunks), t0, last_t)
return chunks


def _llm_call(
model: str,
messages: list[dict],
Expand All@@ -406,7 +534,15 @@ def _llm_call(
bundle=None,
**kwargs,
) -> str:
"""Single LLM call with animated progress and debug logging."""
"""Single LLM call with animated progress and debug logging.

Uses ``stream=True``: some corporate LLM gateways enforce an idle
timeout on buffered (non-streaming) requests, which a long-running
completion can hit before the response is ever sent. Streaming keeps
bytes flowing over the connection so that timeout never fires; the
chunks are merged back into a single response via
:func:`_merge_stream_chunks` so callers see the same shape as before.
"""
messages = _prepare_messages(model, messages)
extra_headers = bundle.extra_headers if bundle is not None else get_extra_headers()
if extra_headers:
Expand All@@ -417,6 +553,7 @@ def _llm_call(
if bundle is not None:
kwargs.setdefault("api_key", bundle.api_key)
kwargs.setdefault("base_url", bundle.base_url)
kwargs.setdefault("stream_options", {"include_usage": True})
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
if kwargs:
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)
Expand All@@ -425,7 +562,11 @@ def _llm_call(
spinner.start()
t0 = time.time()

response = litellm.completion(model=model, messages=messages, **kwargs)
stream = litellm.completion(model=model, messages=messages, stream=True, **kwargs)
chunks = _consume_stream(stream, step_name, t0)
if not chunks:
raise RuntimeError(f"LLM [{step_name}] stream produced no chunks")
response = _merge_stream_chunks(chunks, messages)
content = response.choices[0].message.content or ""
truncated = _warn_if_truncated(response, step_name, kwargs.get("max_tokens"))

Expand All@@ -449,7 +590,10 @@ async def _llm_call_async(
bundle=None,
**kwargs,
) -> str:
"""Async LLM call with timing output and debug logging."""
"""Async LLM call with timing output and debug logging.

See ``_llm_call`` for why ``stream=True`` is used.
"""
messages = _prepare_messages(model, messages)
extra_headers = bundle.extra_headers if bundle is not None else get_extra_headers()
if extra_headers:
Expand All@@ -460,13 +604,21 @@ async def _llm_call_async(
if bundle is not None:
kwargs.setdefault("api_key", bundle.api_key)
kwargs.setdefault("base_url", bundle.base_url)
kwargs.setdefault("stream_options", {"include_usage": True})
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
if kwargs:
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)

t0 = time.time()

response = await litellm.acompletion(model=model, messages=messages, **kwargs)
stream = await litellm.acompletion(model=model, messages=messages, stream=True, **kwargs)
if hasattr(stream, "__aiter__"):
chunks = await _consume_stream_async(stream, step_name, t0)
else:
chunks = _consume_stream(stream, step_name, t0)
if not chunks:
raise RuntimeError(f"LLM [{step_name}] stream produced no chunks")
response = _merge_stream_chunks(chunks, messages)
content = response.choices[0].message.content or ""
truncated = _warn_if_truncated(response, step_name, kwargs.get("max_tokens"))

Expand Down
Loading