Skip to content
Merged
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ TOML at startup:
| `KAAS_WEB_DIR` | `[server] web_dir` | `/app/web/dist` (in Docker) |
| `KAAS_AI_MCP_URL` | `[ai] mcp_url` | _(deprecated — use `KAAS_MCP_ENABLED`)_ |
| `KB_AI_WRITE_TIMEOUT_S` | _(env only)_ | `300` |
| `KB_AI_EXTRACT_TIMEOUT_S` | _(env only)_ | `180` |

Raise `KB_AI_WRITE_TIMEOUT_S` when compiling with a slow local model. A write call
rewrites a whole article, so its latency tracks the article's length rather than the
Expand All @@ -250,6 +251,24 @@ over 900s with a second model sharing the GPU. Size it from the slowest write yo
actually observe, and note the budget is per attempt — a timeout is retried twice,
so the wall-clock cost of a value that is still too low is three times over.

`KB_AI_EXTRACT_TIMEOUT_S` is the same knob for the extract phase, and the phase that
usually needs it first: extract runs on every document, so it is where a slow model
fails earliest. Its 180s default is deliberately tighter than the write phase's,
because extract calls are bounded in size and a single hung one blocks the whole job
— but that figure assumes a hosted model's generation speed. A local 12B model
exhausted all three attempts on a 4386-character prompt, which is a small document.

Size extract more conservatively than the write phase, because it retries in two
places rather than one: the LLM layer retries a timeout twice, and the summarize
strategy's second phase re-dispatches its entire call set once on any failure. A
hung call there costs six attempts to discover — about 1140s at the 180s default,
and over 90 minutes at 900s — so a value that is still too low is expensive twice
over.

Both variables ignore an unusable value (zero, negative, non-numeric, NaN, or
infinite) and say so once on stderr, rather than letting a typo in an env var decide
how a compile behaves.

The docs site has a
[full configuration reference](https://bybit-exchange.github.io/kaas-doc/getting-started/configuration.html)
covering the settings not listed here.
Expand Down
1 change: 1 addition & 0 deletions py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ run's cost. `--force` replaces an existing `derived/<slug>/` from a previous run
| `KB_AI_MAX_PROMPT_CHARS` | `80000` | Prompt character limit; longer prompts are truncated |
| `KB_AI_PRICING` | — | JSON object of `{model: {"input": per-1M-USD, "output": per-1M-USD}}`. Prices models the built-in table lacks; unpriced models report 0.00 USD and warn once. Example: `{"gpt-4o": {"input": 2.5, "output": 10.0}}` |
| `KB_AI_WRITE_TIMEOUT_S` | `300` | Per-call timeout for the write phase. Raise it for a slow local model: a write rewrites the whole article, so its latency tracks the article's length, and a 9-source merge on a local 27B model took 349s. The budget is per attempt and a timeout is retried twice. An unusable value is ignored with a warning |
| `KB_AI_EXTRACT_TIMEOUT_S` | `180` | Per-call timeout for the extract phase. Raise it for a slow local model: the 180s default is sized for a hosted model's generation speed, and a local 12B model exhausted all three attempts on a 4386-character prompt. The budget is per attempt, and this phase retries in two places — the LLM layer retries a timeout twice, and the summarize strategy's second phase re-dispatches its whole call set once more, so the worst case is six attempts. An unusable value is ignored with a warning |
| `KB_WORKERS` | `16` | Compile-pipeline worker concurrency |
| `KAAS_DAEMON_MAX_WORKERS` | `8` | Daemon thread-pool size |
| `KAAS_KB_DIR` | `./data` | Knowledge-base root for the MCP server |
Expand Down
59 changes: 57 additions & 2 deletions py/src/kb_ai/core/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import functools
import hashlib
import json
import math
import os
import re
import sys
Expand All @@ -27,19 +28,73 @@
# Per-call timeout for extract pipeline. Tighter than the default 900s because
# extract calls have predictable size (≤16K max_tokens) and a single hung call
# blocks the whole job (see diagnose log 2026-06-01, T4 d35bec86 stall 914s).
#
# "Predictable size" is not the same as predictable duration: the figure assumes a
# hosted model's generation speed. A model served on localhost can be an order of
# magnitude slower, at which point 180s fails on documents a hosted model handles
# without trouble -- a 12B local model exhausted all three attempts on a
# 4386-character prompt. _EXTRACT_TIMEOUT_ENV exists so that is a configuration
# change rather than a source edit.
#
# Size it knowing this phase retries in two places, unlike write: the LLM layer
# retries a timeout twice, and _phase2_with_retry re-dispatches its entire K-call
# set once on any failure, so a hung phase-2 call costs 6*timeout+60s to discover
# rather than 3*timeout+30s.
#
# _EXTRACT_TIMEOUT_ENV is honoured verbatim, including past DEFAULT_CLIENT_TIMEOUT_S.
# The client timeout is a default, not a ceiling -- _completion.py applies an override
# with client.with_options(timeout=...), which replaces the value rather than clamping
# it, so an operator who needs 1200 gets 1200.
_EXTRACT_CALL_TIMEOUT_S = 180.0
_EXTRACT_TIMEOUT_ENV = "KB_AI_EXTRACT_TIMEOUT_S"


@functools.lru_cache(maxsize=1)
def _warn_unusable_extract_timeout(raw: str) -> None:
"""Report an ignored override once, not once per extract call.

Keyed on the raw string, matching merge.py's write-phase twin and _cost.py's
handling of KB_AI_PRICING, so a corrected value is reported afresh rather than
swallowed by the cache.
"""
print(f"[extract] ignoring {_EXTRACT_TIMEOUT_ENV}={raw!r}: expected a positive "
f"number of seconds — using {_EXTRACT_CALL_TIMEOUT_S}", file=sys.stderr)


def _extract_call_timeout() -> float:
"""The per-call extract timeout, re-read on every decorated entry.

Read per call rather than at import like the neighbouring MAX_PROMPT_CHARS, so
that setting the variable does not have to happen before kb_ai is imported.

A value that cannot serve as a timeout is reported and ignored rather than
honoured: '0' or a negative would fail every extract call instantly, and a
non-finite one would silently remove the cap -- reinstating the hung-call stall
the default was introduced to bound.
"""
raw = os.environ.get(_EXTRACT_TIMEOUT_ENV, "")
if not raw:
return _EXTRACT_CALL_TIMEOUT_S
try:
seconds = float(raw)
except ValueError:
seconds = 0.0
if seconds > 0 and math.isfinite(seconds):
return seconds
_warn_unusable_extract_timeout(raw)
return _EXTRACT_CALL_TIMEOUT_S


def _with_extract_timeout(fn):
"""Apply _EXTRACT_CALL_TIMEOUT_S to all LLM calls within fn, restoring on exit.
"""Apply the extract-phase call timeout to all LLM calls within fn.

Restoring to prev (not None) keeps nested invocations safe — if a future
caller wraps extract in its own timeout context, we don't clobber it.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
prev = get_call_timeout()
set_call_timeout(_EXTRACT_CALL_TIMEOUT_S)
set_call_timeout(_extract_call_timeout())
try:
return fn(*args, **kwargs)
finally:
Expand Down
3 changes: 2 additions & 1 deletion py/src/kb_ai/llm/_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@

# Client-wide HTTP timeout: the default every call gets, not a ceiling. A per-call
# override goes through client.with_options(timeout=...), which replaces this value
# in either direction, so KB_AI_WRITE_TIMEOUT_S can legitimately exceed it.
# in either direction, so KB_AI_WRITE_TIMEOUT_S and KB_AI_EXTRACT_TIMEOUT_S can
# legitimately exceed it.
# Deliberately generous because it has to cover the slowest call any phase makes;
# a phase that knows its own calls are smaller overrides it via set_call_timeout.
DEFAULT_CLIENT_TIMEOUT_S = 900.0
Expand Down
23 changes: 13 additions & 10 deletions py/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,26 @@


@pytest.fixture(autouse=True)
def _no_write_timeout_override(monkeypatch):
"""Keep KB_AI_WRITE_TIMEOUT_S out of the suite's view.
def _no_phase_timeout_overrides(monkeypatch):
"""Keep the per-phase timeout overrides out of the suite's view.

Tests across three files assert the write phase's default call timeout. The
operator that override exists for -- someone running a slow local model -- will
have it exported, and would otherwise get a red suite for a reason that has
nothing to do with their change. A test that wants the override sets it in its
own body, which runs after this.
Tests across four files assert the extract and write phases' default call
timeouts. The operator those overrides exist for -- someone running a slow local
model -- will have them exported, and would otherwise get a red suite for a
reason that has nothing to do with their change. A test that wants an override
sets it in its own body, which runs after this.

The warn-once cache is module-level state keyed on the value it warned about, so
it is reset here too: otherwise the first test to warn would decide whether a
later one sees its own warning, making the order matter.
The warn-once caches are module-level state keyed on the value they warned about,
so they are reset here too: otherwise the first test to warn would decide whether
a later one sees its own warning, making the order matter.
"""
from kb_ai.core.extract import _warn_unusable_extract_timeout
from kb_ai.core.merge import _warn_unusable_write_timeout

monkeypatch.delenv("KB_AI_WRITE_TIMEOUT_S", raising=False)
monkeypatch.delenv("KB_AI_EXTRACT_TIMEOUT_S", raising=False)
_warn_unusable_write_timeout.cache_clear()
_warn_unusable_extract_timeout.cache_clear()


@pytest.fixture
Expand Down
130 changes: 130 additions & 0 deletions py/tests/test_core_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,136 @@ def documented():
assert documented.__doc__ == "A docstring."


# ── raising the extract timeout for a slow model ─────────────────────
#
# 180s is sized for a hosted model. A local one generates at a fraction of that
# speed, and extract then fails on documents a hosted model handles easily: a
# 12B model served on localhost exhausted all three attempts on a 4386-character
# prompt, which is a small document by this corpus's standards. Without an
# override the only way past it was to edit the source.
#
# Mirrors KB_AI_WRITE_TIMEOUT_S in kb_ai.core.merge deliberately, down to the
# fallback and warn-once behaviour, so an operator who has learned one knob has
# learned both.

def test_a_slower_model_can_raise_the_extract_timeout(monkeypatch):
monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "900.5")

assert ex._extract_call_timeout() == 900.5


def test_the_extract_timeout_is_read_per_call_not_frozen_at_import(
fresh_context, monkeypatch
):
"""Whoever launches a compile sets the env var, usually long after import."""
from kb_ai.llm import get_call_timeout

observed = []

@ex._with_extract_timeout
def probe():
observed.append(get_call_timeout())

probe()
monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "900")
probe()

assert observed == [180.0, 900.0]


@pytest.mark.parametrize("junk", ["abc", "180s", "900ms", "0", "-5", "nan", "inf"])
def test_an_unusable_extract_timeout_falls_back_to_the_default(monkeypatch, junk):
"""A typo must not decide how a compile behaves.

'0' and '-5' would fail every extract call instantly; 'inf' would silently
remove the cap this override exists to impose, reinstating the hung-call
stall the 180s default was introduced to bound.
"""
monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", junk)

assert ex._extract_call_timeout() == 180.0


def test_an_unusable_extract_timeout_says_so_once(monkeypatch, capsys):
"""Silence here means believing an override took effect when it did not."""
monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "900ms")
ex._extract_call_timeout()
ex._extract_call_timeout()

warnings = [line for line in capsys.readouterr().err.splitlines()
if "KB_AI_EXTRACT_TIMEOUT_S" in line]
assert len(warnings) == 1
# The value has to appear, or the reader cannot tell which typo was ignored.
assert "900ms" in warnings[0]
assert "180.0" in warnings[0]


def test_a_usable_extract_timeout_is_not_warned_about(monkeypatch, capsys):
monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "600")
assert ex._extract_call_timeout() == 600.0
assert "KB_AI_EXTRACT_TIMEOUT_S" not in capsys.readouterr().err


def test_an_absent_extract_timeout_is_not_warned_about(capsys):
"""Unset is the normal case, not a misconfiguration."""
assert ex._extract_call_timeout() == 180.0
assert capsys.readouterr().err == ""


def test_a_raised_extract_timeout_reaches_the_llm_call(fresh_context, monkeypatch):
"""The knob is worthless if it stops at _extract_call_timeout.

Asserts the value observed from inside a decorated call, which is where the
LLM seam reads it, and that it does not leak out past the call.
"""
from kb_ai.llm import get_call_timeout

monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "1800")
seen = {}

@ex._with_extract_timeout
def probe():
seen["timeout"] = get_call_timeout()

probe()

assert seen["timeout"] == 1800.0
assert get_call_timeout() is None, "the override must not leak past the call"


def _drive_summarized(monkeypatch, record):
monkeypatch.setattr(ex, "completion", lambda **kw: (record(), "summary")[1])
monkeypatch.setattr(ex, "completion_json", lambda **kw: (record(), {})[1])
ex.extract_knowledge_summarized(["a", "b"], {}, "sum", "ext")


def _drive_chunked(monkeypatch, record):
monkeypatch.setattr(ex, "completion_json", lambda **kw: (record(), {})[1])
ex.extract_knowledge_chunked("a short body", model="ext")


@pytest.mark.parametrize("drive", [_drive_summarized, _drive_chunked],
ids=["summarized", "chunked"])
def test_a_raised_extract_timeout_reaches_every_extract_entry_point(
drive, fresh_context, monkeypatch
):
"""Both decorated entry points, not just the decorator in isolation.

A knob that reaches one extract path and not the other is worse than none: the
dispatch between them depends on document size and transcript detection, so the
gap would show up as a timeout that only some documents respect.
"""
from kb_ai.llm import get_call_timeout

monkeypatch.setenv("KB_AI_EXTRACT_TIMEOUT_S", "1800")
seen = {}

drive(monkeypatch, lambda: seen.setdefault("timeout", get_call_timeout()))

assert seen["timeout"] == 1800.0
assert get_call_timeout() is None, "the override must not leak past the call"


# ── worker context adoption ─────────────────────────────────────────
#
# Every fan-out here submits with a bare pool.submit, so a worker starts on a
Expand Down
Loading