Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions pageindex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@
def _preload_litellm() -> None:
"""Start litellm's multi-second import in the background, once per
process — a per-client thread would churn under per-request clients."""
# LiteLLM's import otherwise fetches its model map over the network —
# seconds of blocking (or a hang offline). Stamped here, not at package
# import, so merely importing pageindex leaves the host process's own
# litellm untouched; setdefault, so an explicit user choice wins.
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
global _litellm_preload_started
if _litellm_preload_started:
return
Expand Down
8 changes: 7 additions & 1 deletion pageindex/local_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ def _openai_model(protocol: str, model_name: str, backend=None):
"installed. Run: pip install 'litellm>=1.97'"
)
from .utils import (_litellm_model, _mute_litellm_bridge_usage_warning,
_repair_litellm_types)
_quiet_litellm, _repair_litellm_types)
_repair_litellm_types()
_mute_litellm_bridge_usage_warning()
_quiet_litellm()
try:
wire = _litellm_model(model_name)
except litellm.NotFoundError as exc:
Expand All @@ -245,6 +246,8 @@ def _litellm_claude_marks(wire: str) -> Optional[dict]:
hands bare names to LiteLLM's own resolution)."""
try:
from litellm import get_llm_provider
from .utils import _quiet_litellm
_quiet_litellm()
model, provider, _, _ = get_llm_provider(model=wire)
except Exception:
return None
Expand Down Expand Up @@ -280,6 +283,8 @@ def _openai_protocol(model_name: str) -> bool:
return True
try:
import litellm
from .utils import _quiet_litellm
_quiet_litellm()
_, provider, _, _ = litellm.get_llm_provider(model=wire)
except Exception:
return False
Expand Down Expand Up @@ -995,6 +1000,7 @@ def _default_max_tokens(model: str, thinking=None) -> int:
if isinstance(budget, int) and not isinstance(budget, bool):
want = budget + 8192
try:
from . import utils # noqa: F401 — must precede litellm's import
import litellm
ceiling = (litellm.model_cost.get(model)
or {}).get("max_output_tokens")
Expand Down
21 changes: 19 additions & 2 deletions pageindex/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from io import BytesIO
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True))
# litellm's import fetches its model map over the network unless told not to.
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
import logging
import yaml
from pathlib import Path
Expand Down Expand Up @@ -58,6 +60,19 @@ def _mute_litellm_bridge_usage_warning() -> None:
message=r"Pydantic serializer warnings:\s+"
r"(PydanticSerializationUnexpectedValue\()?Expected `ResponseAPIUsage`")


def _quiet_litellm() -> None:
"""Mute litellm's stdout "Provider List:" banner and default its loggers
to LITELLM_LOG (ERROR unset); a level set elsewhere stays."""
import litellm
litellm.suppress_debug_info = True
level = getattr(logging, os.environ.get("LITELLM_LOG", "ERROR").upper(),
logging.ERROR)
for name in ("LiteLLM", "LiteLLM Router", "LiteLLM Proxy", "litellm"):
logger = logging.getLogger(name)
if logger.level == logging.NOTSET:
logger.setLevel(level)

# Backward compatibility: support CHATGPT_API_KEY as alias for OPENAI_API_KEY
if not os.getenv("OPENAI_API_KEY") and os.getenv("CHATGPT_API_KEY"):
import warnings
Expand Down Expand Up @@ -150,6 +165,7 @@ def llm_completion(model, prompt, chat_history=None, return_finish_reason=False)
backend = _llm_backend.get()
model = _litellm_model(model)
_repair_litellm_types()
_quiet_litellm()
for i in range(max_retries):
try:
response = litellm.completion(**{
Expand All @@ -168,9 +184,9 @@ def llm_completion(model, prompt, chat_history=None, return_finish_reason=False)
except Exception as e:
if getattr(e, "status_code", None) in _NO_RETRY_STATUS:
raise
print('************* Retrying *************')
logging.error(f"Error: {e}")
if i < max_retries - 1:
logging.warning("Retrying LLM completion")
time.sleep(1)
else:
raise LLMRetriesExhausted(
Expand All @@ -186,6 +202,7 @@ async def llm_acompletion(model, prompt):
backend = _llm_backend.get()
model = _litellm_model(model)
_repair_litellm_types()
_quiet_litellm()
for i in range(max_retries):
try:
response = await litellm.acompletion(**{
Expand All @@ -199,9 +216,9 @@ async def llm_acompletion(model, prompt):
except Exception as e:
if getattr(e, "status_code", None) in _NO_RETRY_STATUS:
raise
print('************* Retrying *************')
logging.error(f"Error: {e}")
if i < max_retries - 1:
logging.warning("Retrying LLM completion")
await asyncio.sleep(1)
else:
raise LLMRetriesExhausted(
Expand Down
4 changes: 0 additions & 4 deletions run_pageindex.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@
from pageindex.page_index_md import md_to_tree
from pageindex.utils import ConfigLoader

# Keep LiteLLM's import off the network (frozen bundled model-cost map);
# an explicit user setting wins.
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")

if __name__ == "__main__":
# Set up argument parser
parser = argparse.ArgumentParser(description='Process PDF or Markdown document and generate structure')
Expand Down
3 changes: 2 additions & 1 deletion tests/test_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,8 @@ def test_non_string_doc_name_stays_not_found(client, store_path):


@pytest.mark.parametrize("repair", ["_repair_litellm_types",
"_mute_litellm_bridge_usage_warning"])
"_mute_litellm_bridge_usage_warning",
"_quiet_litellm"])
def test_openai_agent_config_repairs_litellm_types(tmp_path, monkeypatch,
repair):
"""The BYO path resolves its model through LiteLLM in the caller's
Expand Down
38 changes: 38 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1967,3 +1967,41 @@ def test_blank_chat_model_carries_no_model_into_agent_config():
client = PageIndexClient()
client.chat_model = " "
assert "model" not in client.openai_agent_config()


def test_retry_notice_logs_instead_of_stdout(monkeypatch, capsys, caplog):
"""A retried completion must not write into the caller's stdout — that
channel belongs to answers and CLI output; the notice rides logging
beside the error it accompanies."""
litellm = pytest.importorskip("litellm")
from pageindex import utils
attempts = []

def flaky(**kwargs):
if not attempts:
attempts.append(1)
raise RuntimeError("boom")
message = types.SimpleNamespace(content="ok")
choice = types.SimpleNamespace(message=message, finish_reason="stop")
return types.SimpleNamespace(choices=[choice])

monkeypatch.setattr(litellm, "completion", flaky)
monkeypatch.setattr(utils.time, "sleep", lambda s: None)
assert utils.llm_completion("openai/gpt-x", "hi") == "ok"
assert capsys.readouterr().out == ""
assert any("Retrying" in r.getMessage() for r in caplog.records)


def test_retry_notice_only_when_a_retry_follows(monkeypatch, caplog):
"""The notice announces a retry; the terminal attempt raises instead."""
litellm = pytest.importorskip("litellm")
from pageindex import utils

def broken(**kwargs):
raise RuntimeError("boom")

monkeypatch.setattr(litellm, "completion", broken)
monkeypatch.setattr(utils.time, "sleep", lambda s: None)
with pytest.raises(utils.LLMRetriesExhausted):
utils.llm_completion("openai/gpt-x", "hi")
assert sum("Retrying" in r.getMessage() for r in caplog.records) == 9
57 changes: 57 additions & 0 deletions tests/test_local_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2087,6 +2087,63 @@ def test_litellm_lane_hides_the_bridge_usage_warning():
assert any("Expected `int`" in m for m in seen)


@needs_agents
def test_litellm_lane_mutes_the_provider_list_banner(monkeypatch, capsys):
"""litellm's OpenRouter adapter probes supports_reasoning() with the
provider-stripped model name, so any model missing from its static map
print()s a red "Provider List:" banner into the middle of the streamed
answer; building the lane's model flips litellm's embedder switch."""
litellm = pytest.importorskip("litellm")
monkeypatch.setattr(litellm, "suppress_debug_info", False)
local_chat._openai_model("chat", "openrouter/z-ai/not-in-the-map")
assert litellm.suppress_debug_info is True
with pytest.raises(litellm.BadRequestError):
litellm.get_llm_provider("z-ai/not-in-the-map")
assert "Provider List" not in capsys.readouterr().out


@needs_agents
def test_litellm_lane_gates_litellm_logging(monkeypatch, caplog):
"""The lane defaults litellm's logger to ERROR; a level the host set stays."""
pytest.importorskip("litellm")
import logging
monkeypatch.delenv("LITELLM_LOG", raising=False)
caplog.set_level(logging.NOTSET, logger="LiteLLM") # untouched
gated = logging.getLogger("LiteLLM")
local_chat._openai_model("chat", "openrouter/z-ai/not-in-the-map")
assert gated.level == logging.ERROR
assert not gated.isEnabledFor(logging.WARNING)
gated.setLevel(logging.WARNING)
local_chat._openai_model("chat", "openrouter/z-ai/not-in-the-map")
assert gated.level == logging.WARNING


def test_provider_lookups_flip_the_switch_before_asking(monkeypatch, capsys):
"""The lane asks litellm which provider serves a model before the
model is built, and openai_agent_config asks with no model built at
all; a failed ask print()s the banner, so the switch flips at the ask."""
litellm = pytest.importorskip("litellm")
for lookup in (local_chat._openai_protocol,
local_chat._litellm_claude_marks):
monkeypatch.setattr(litellm, "suppress_debug_info", False)
assert not lookup("z-ai/not-in-the-map")
assert litellm.suppress_debug_info is True
assert "Provider List" not in capsys.readouterr().out


def test_quiet_litellm_honors_the_chosen_default(monkeypatch, caplog):
"""LITELLM_LOG picks the default, in both directions."""
pytest.importorskip("litellm")
import logging
from pageindex.utils import _quiet_litellm
gated = logging.getLogger("LiteLLM")
for chosen in (logging.CRITICAL, logging.DEBUG):
monkeypatch.setenv("LITELLM_LOG", logging.getLevelName(chosen))
caplog.set_level(logging.NOTSET, logger="LiteLLM")
_quiet_litellm()
assert gated.level == chosen


def test_openai_protocol_predicate_follows_litellm_routing():
pytest.importorskip("litellm")
for name in ("gpt-5", "openai/gpt-4o", "litellm/gpt-4o",
Expand Down
30 changes: 30 additions & 0 deletions tests/test_package_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,33 @@ def test_import_leaves_litellm_env_untouched(tmp_path):
out = subprocess.run([sys.executable, "-c", probe], env=env,
capture_output=True, text=True, check=True)
assert out.stdout.strip() == "ok"


def test_chat_module_stamps_before_it_imports_litellm():
"""local_chat imports litellm without utils on its import path; the
stamp has to be in place by then anyway."""
probe = ("import os\n"
"from pageindex import local_chat\n"
"local_chat._default_max_tokens('claude-sonnet-4-5',"
" {'budget_tokens': 4096})\n"
"print(os.environ.get('LITELLM_LOCAL_MODEL_COST_MAP'))\n")
env = {k: v for k, v in os.environ.items()
if k != "LITELLM_LOCAL_MODEL_COST_MAP"}
out = subprocess.run([sys.executable, "-c", probe], env=env,
capture_output=True, text=True, check=True)
assert out.stdout.strip() == "True"


def test_utils_import_keeps_litellm_off_the_network():
"""utils' import sets litellm's no-fetch default; an explicit choice wins."""
probe = ("import os, pageindex.utils; "
"print(os.environ['LITELLM_LOCAL_MODEL_COST_MAP'])")
env = {k: v for k, v in os.environ.items()
if k != "LITELLM_LOCAL_MODEL_COST_MAP"}
fresh = subprocess.run([sys.executable, "-c", probe], env=env,
capture_output=True, text=True, check=True)
assert fresh.stdout.strip() == "True"
env["LITELLM_LOCAL_MODEL_COST_MAP"] = "False"
chosen = subprocess.run([sys.executable, "-c", probe], env=env,
capture_output=True, text=True, check=True)
assert chosen.stdout.strip() == "False"