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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', '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('^' + ".*" + ' feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, '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" + ' feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, '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('^' + ".*" + ' feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, '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); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(config): configurable LLM request timeout by KylinMountain · Pull Request #134 · VectifyAI/OpenKB · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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