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
4 changes: 4 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
# - organization
# - dataset
# - model

# Optional: per-request LLM timeout in seconds, forwarded to LiteLLM.
# Defaults to LiteLLM's 600s; raise it for slow local backends (e.g. Ollama).
# timeout: 1200
13 changes: 12 additions & 1 deletion openkb/agent/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@
import litellm

from openkb import frontmatter
from openkb.config import DEFAULT_ENTITY_TYPES, get_extra_headers, resolve_entity_types
from openkb.config import (
DEFAULT_ENTITY_TYPES,
get_extra_headers,
get_timeout,
resolve_entity_types,
)
from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks
from openkb.schema import INDEX_SEED, get_agents_md

Expand Down Expand Up @@ -326,6 +331,9 @@ def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str
extra_headers = get_extra_headers()
if extra_headers:
kwargs.setdefault("extra_headers", extra_headers)
timeout = get_timeout()
if timeout is not None:
kwargs.setdefault("timeout", timeout)
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 @@ -348,6 +356,9 @@ async def _llm_call_async(model: str, messages: list[dict], step_name: str, **kw
extra_headers = get_extra_headers()
if extra_headers:
kwargs.setdefault("extra_headers", extra_headers)
timeout = get_timeout()
if timeout is not None:
kwargs.setdefault("timeout", timeout)
logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages))
if kwargs:
logger.debug("LLM kwargs [%s]: %s", step_name, kwargs)
Expand Down
7 changes: 5 additions & 2 deletions openkb/agent/linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from agents.model_settings import ModelSettings

from openkb.agent.tools import list_wiki_files, read_wiki_file
from openkb.config import get_extra_headers
from openkb.config import get_extra_headers, get_timeout_extra_args

MAX_TURNS = 50
from openkb.schema import get_agents_md
Expand Down Expand Up @@ -81,7 +81,10 @@ def read_file(path: str) -> str:
instructions=instructions,
tools=[list_files, read_file],
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
model_settings=ModelSettings(
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)


Expand Down
3 changes: 2 additions & 1 deletion openkb/agent/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from agents import Agent, Runner, function_tool

from agents import ToolOutputImage, ToolOutputText
from openkb.config import get_extra_headers
from openkb.config import get_extra_headers, get_timeout_extra_args
from openkb.agent.tools import (
get_wiki_page_content,
read_wiki_file,
Expand Down Expand Up @@ -98,6 +98,7 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
model_settings=ModelSettings(
parallel_tool_calls=False,
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)

Expand Down
8 changes: 6 additions & 2 deletions openkb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def filter(self, record: logging.LogRecord) -> bool:

from openkb.config import (
DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb,
resolve_extra_headers, set_extra_headers,
resolve_extra_headers, set_extra_headers, resolve_timeout, set_timeout,
)
from openkb.converter import _registry_path, convert_document
from openkb.locks import atomic_write_json, atomic_write_text, kb_ingest_lock, kb_read_lock
Expand Down Expand Up @@ -108,17 +108,21 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None:

api_key = os.environ.get("LLM_API_KEY", "")

# Try to resolve the active provider and extra headers from the KB config
# Try to resolve the active provider, extra headers, and request timeout
# from the KB config
provider: str | None = None
extra_headers: dict[str, str] = {}
timeout: float | None = None
if kb_dir is not None:
config_path = kb_dir / ".openkb" / "config.yaml"
if config_path.exists():
config = load_config(config_path)
model = config.get("model", "")
provider = _extract_provider(str(model))
extra_headers = resolve_extra_headers(config)
timeout = resolve_timeout(config)
set_extra_headers(extra_headers)
set_timeout(timeout)

if not api_key:
# Check if any provider key is already set. OAuth-based providers
Expand Down
59 changes: 59 additions & 0 deletions openkb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import contextlib
import logging
import math
import re
from pathlib import Path
from typing import Any, Iterator
Expand Down Expand Up @@ -136,6 +137,41 @@ def resolve_extra_headers(config: dict) -> dict[str, str]:
return headers


def resolve_timeout(config: dict) -> float | None:
"""Resolve the optional ``timeout:`` key to a finite positive number of seconds.

Returns ``None`` (use LiteLLM's default) when absent or invalid; rejects
bools and ``nan``/``inf``, warning when present but unusable.
"""
raw = config.get("timeout")
if raw is None:
return None
if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
logger.warning(
"config: 'timeout' must be a positive number of seconds, got %s — "
"ignoring it.",
type(raw).__name__,
)
return None
try:
value = float(raw)
except (TypeError, ValueError):
logger.warning(
"config: 'timeout' must be a positive number of seconds, got %r — "
"ignoring it.",
raw,
)
return None
if not math.isfinite(value) or value <= 0:
logger.warning(
"config: 'timeout' must be a finite positive number of seconds, got "
"%s — ignoring it.",
value,
)
return None
return value


# Process-wide extra headers for LLM requests, resolved from the active KB's
# config by the CLI entry points (cli._setup_llm_key). LLM call sites read it
# via get_extra_headers() so the value doesn't have to be threaded through
Expand All @@ -155,6 +191,29 @@ def get_extra_headers() -> dict[str, str]:
return dict(_runtime_extra_headers)


# Process-wide LLM request timeout (seconds), set from config by the CLI and
# read at the call sites via get_timeout(). None = use LiteLLM's default.
_runtime_timeout: float | None = None


def set_timeout(timeout: float | None) -> None:
"""Set the process-wide LLM request timeout in seconds; ``None`` clears it."""
global _runtime_timeout
_runtime_timeout = timeout


def get_timeout() -> float | None:
"""Return the process-wide LLM request timeout in seconds, or ``None``."""
return _runtime_timeout


def get_timeout_extra_args() -> dict[str, float] | None:
"""Timeout as Agents-SDK ``ModelSettings.extra_args`` (it has no ``timeout``
field), or ``None``. The LiteLLM provider forwards it to the completion call.
"""
return {"timeout": _runtime_timeout} if _runtime_timeout is not None else None


def load_config(config_path: Path) -> dict[str, Any]:
"""Load YAML config from config_path, merged with DEFAULT_CONFIG.

Expand Down
3 changes: 2 additions & 1 deletion openkb/skill/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from agents import Agent, Runner, ToolOutputImage, ToolOutputText, function_tool
from agents.model_settings import ModelSettings

from openkb.config import get_extra_headers
from openkb.config import get_extra_headers, get_timeout_extra_args
from openkb.skill import skill_dir
from openkb.skill.tools import (
get_skill_page_content as _get_page_content_impl,
Expand Down Expand Up @@ -154,6 +154,7 @@ def done(summary: str) -> str:
model_settings=ModelSettings(
parallel_tool_calls=True,
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)

Expand Down
17 changes: 13 additions & 4 deletions openkb/skill/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from agents.exceptions import MaxTurnsExceeded
from agents.model_settings import ModelSettings

from openkb.config import get_extra_headers
from openkb.config import get_extra_headers, get_timeout_extra_args
from openkb.skill import extract_body, extract_frontmatter


Expand Down Expand Up @@ -243,7 +243,10 @@ async def generate_eval_set(
name="eval-set-generator",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
model_settings=ModelSettings(
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)
try:
result = await Runner.run(agent, "Generate the eval set now.", max_turns=3)
Expand Down Expand Up @@ -302,7 +305,10 @@ async def grade_one(
name="trigger-grader",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
model_settings=ModelSettings(
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)
try:
result = await Runner.run(agent, f"Question: {question}", max_turns=2)
Expand Down Expand Up @@ -351,7 +357,10 @@ async def grade_coverage(
name="coverage-grader",
instructions=instructions,
model=f"litellm/{model}",
model_settings=ModelSettings(extra_headers=get_extra_headers() or None),
model_settings=ModelSettings(
extra_headers=get_extra_headers() or None,
extra_args=get_timeout_extra_args(),
),
)
try:
result = await Runner.run(agent, f"Question: {question}", max_turns=2)
Expand Down
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

@pytest.fixture(autouse=True)
def _reset_extra_headers():
"""Keep the process-wide LLM extra-headers stash from leaking across tests."""
from openkb.config import set_extra_headers
"""Keep the process-wide LLM extra-headers / timeout stashes from leaking across tests."""
from openkb.config import set_extra_headers, set_timeout
yield
set_extra_headers({})
set_timeout(None)


@pytest.fixture
Expand Down
21 changes: 20 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,12 +373,14 @@ class TestSetupLlmKey:
"""_setup_llm_key: OAuth-provider warning skip + extra-headers stash."""

@staticmethod
def _make_kb(tmp_path, model, extra_headers=None):
def _make_kb(tmp_path, model, extra_headers=None, timeout=None):
openkb_dir = tmp_path / ".openkb"
openkb_dir.mkdir()
config = {"model": model}
if extra_headers is not None:
config["extra_headers"] = extra_headers
if timeout is not None:
config["timeout"] = timeout
(openkb_dir / "config.yaml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
Expand Down Expand Up @@ -445,3 +447,20 @@ def test_extra_headers_reset_when_config_has_none(self, tmp_path):
kb = self._make_kb(tmp_path, "gpt-5.4-mini")
_setup_llm_key(kb)
assert get_extra_headers() == {}

def test_timeout_stashed_from_config(self, tmp_path):
from openkb.cli import _setup_llm_key
from openkb.config import get_timeout

kb = self._make_kb(tmp_path, "gpt-5.4-mini", timeout=1200)
_setup_llm_key(kb)
assert get_timeout() == 1200.0

def test_timeout_reset_when_config_has_none(self, tmp_path):
from openkb.cli import _setup_llm_key
from openkb.config import get_timeout, set_timeout

set_timeout(999.0)
kb = self._make_kb(tmp_path, "gpt-5.4-mini")
_setup_llm_key(kb)
assert get_timeout() is None
48 changes: 48 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from openkb.config import (
DEFAULT_CONFIG,
get_extra_headers,
get_timeout,
load_config,
resolve_extra_headers,
resolve_timeout,
save_config,
set_extra_headers,
set_timeout,
)


Expand Down Expand Up @@ -102,3 +105,48 @@ def test_extra_headers_stash_roundtrip_and_isolation():
assert get_extra_headers() == {"A": "1"}
set_extra_headers({})
assert get_extra_headers() == {}


# --- timeout -----------------------------------------------------------------

def test_resolve_timeout_absent_returns_none():
assert resolve_timeout({}) is None


def test_resolve_timeout_int_and_float():
assert resolve_timeout({"timeout": 1200}) == 1200.0
assert resolve_timeout({"timeout": 0.5}) == 0.5


def test_resolve_timeout_numeric_string_coerced():
assert resolve_timeout({"timeout": "1200"}) == 1200.0


def test_resolve_timeout_rejects_non_positive():
assert resolve_timeout({"timeout": 0}) is None
assert resolve_timeout({"timeout": -10}) is None


def test_resolve_timeout_rejects_bool():
# bool is a subclass of int; True/False are not durations.
assert resolve_timeout({"timeout": True}) is None


def test_resolve_timeout_rejects_non_numeric():
assert resolve_timeout({"timeout": "soon"}) is None
assert resolve_timeout({"timeout": [1200]}) is None


def test_resolve_timeout_rejects_nan_and_inf():
# nan/inf pass a naive `<= 0` check; YAML's .nan/.inf yield real floats.
assert resolve_timeout({"timeout": float("inf")}) is None
assert resolve_timeout({"timeout": float("nan")}) is None
assert resolve_timeout({"timeout": "inf"}) is None
assert resolve_timeout({"timeout": "nan"}) is None


def test_timeout_stash_roundtrip_and_reset():
set_timeout(1200.0)
assert get_timeout() == 1200.0
set_timeout(None)
assert get_timeout() is None
Loading