From 4e932bc62135723f04f5ae024cdd96d78d9a7842 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 17 Aug 2026 01:32:19 +0800 Subject: [PATCH 1/2] fix: rebuild litellm's Message/Delta types on Python 3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit litellm 1.97.0 ships Message and Delta annotations whose nested forward refs (ChatCompletionReasoningSummaryTextBlock et al) do not resolve on 3.10, so every completion() dies constructing its response object — non-stream and stream alike (upstream BerriAI/litellm#36384, open, no patch release; 1.96.2 is clean, so the floor raise surfaced it, and pydantic 2.12/2.13 both reproduce). The repair rebuilds the two models once with their defining modules' namespaces at our three completion gateways; version-gated to <3.11 and best-effort, so it is a no-op on healthy interpreters and future fixed litellm releases. Verified on a 3.10 venv: the previously failing anthropic wire test and the full suite pass (250 green, matching CI's matrix leg). --- pageindex/local_chat.py | 2 ++ pageindex/utils.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index c17619b20..5cf7be538 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -254,6 +254,8 @@ def _openai_model(protocol: str, model_name: str): f"'{model_name}' routes through LiteLLM, but litellm is not " "installed. Run: pip install 'litellm>=1.97'" ) + from .utils import _repair_litellm_types + _repair_litellm_types() wire = model_name.removeprefix("litellm/") if "/" not in wire or wire.startswith("openai/"): if not os.environ.get("OPENAI_API_KEY"): diff --git a/pageindex/utils.py b/pageindex/utils.py index 9e6243c96..d12b32894 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -1,5 +1,6 @@ import logging import os +import sys import textwrap from datetime import datetime import time @@ -19,6 +20,23 @@ # litellm is imported inside the functions that use it; eager import is slow # and fetches a remote model-cost map. + +def _repair_litellm_types() -> None: + """litellm 1.97.0's Message/Delta annotations carry nested forward refs + Python 3.10 cannot resolve (BerriAI/litellm#36384), so every completion + dies constructing its response. Rebuild them once with the defining + modules' names; no-op on 3.11+ and on fixed litellm releases.""" + if sys.version_info >= (3, 11): + return + try: + import litellm.types.llms.openai as openai_types + import litellm.types.utils as litellm_types + namespace = {**vars(openai_types), **vars(litellm_types)} + litellm_types.Message.model_rebuild(_types_namespace=namespace) + litellm_types.Delta.model_rebuild(_types_namespace=namespace) + except Exception: + pass # best-effort: a failed repair leaves litellm's own error + # 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"): os.environ["OPENAI_API_KEY"] = os.getenv("CHATGPT_API_KEY") @@ -81,6 +99,7 @@ def llm_completion(model, prompt, chat_history=None, return_finish_reason=False) ) else: import litellm + _repair_litellm_types() response = litellm.completion( model=model, messages=messages, @@ -127,6 +146,7 @@ async def llm_acompletion(model, prompt): ) else: import litellm + _repair_litellm_types() response = await litellm.acompletion( model=model, messages=messages, From 752886c02414343386c9baf2def634b6e143e043 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 17 Aug 2026 01:33:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20chat()=20takes=20reasoning=5Feffort?= =?UTF-8?q?=20=E2=80=94=20the=20front=20door's=20one=20thinking=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruled in as a business-level control alongside model: who answers, and how hard it thinks. Same name, values, and verbatim semantics as chat_completions underneath (LiteLLM's cross-provider tier string); unset sends nothing so each backend's own default behavior applies. Sampling and wire-level knobs deliberately stay off the front door. --- pageindex/client.py | 8 +++++++- tests/test_local_chat.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pageindex/client.py b/pageindex/client.py index 9a12434da..b69168267 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -382,6 +382,7 @@ def chat( doc_id: Optional[Union[str, list[str]]] = None, stream: bool = False, model: Optional[str] = None, + reasoning_effort: Optional[str] = None, ) -> Union[str, Iterator[str]]: """ Ask a question about your documents, get the answer. @@ -401,13 +402,18 @@ def chat( stream: Yield the answer as text chunks as it is produced. model: Local only — backend model name (defaults to ``chat_model``). + reasoning_effort: Local only — how hard the model thinks + (``"low"`` / ``"medium"`` / ``"high"``; what a backend + accepts is its own). Unset sends nothing — the model's + default behavior applies. Returns: - stream=False: the answer string - stream=True: iterator of text chunks """ result = self.chat_completions(messages, stream=stream, - doc_id=doc_id, model=model) + doc_id=doc_id, model=model, + reasoning_effort=reasoning_effort) if stream: return cast(Iterator[str], result) envelope = cast(dict[str, Any], result) diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index aeb0d2875..c7c707b38 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -282,6 +282,8 @@ def test_cloud_guards(): with pytest.raises(PageIndexAPIError, match="local-mode"): cloud.chat_completions([{"role": "user", "content": "x"}], max_tokens=256) + with pytest.raises(PageIndexAPIError, match="local-mode"): + cloud.chat("x", reasoning_effort="low") with pytest.raises(PageIndexAPIError, match="not available on PageIndex " "cloud yet"): cloud.responses("x") @@ -403,6 +405,28 @@ def test_chat_multi_turn_history(client, store_path, fake_model): assert fake.inputs[0][-3:] == history +@needs_agents +def test_chat_reasoning_effort_reaches_the_engine(client, store_path, + fake_model, monkeypatch): + """The business door's one thinking knob rides chat_completions' + channel unchanged; unset sends nothing.""" + seen = {} + real = local_chat._openai_agent + + def spy(*args, **kwargs): + agent = real(*args, **kwargs) + seen["settings"] = agent.model_settings + return agent + + monkeypatch.setattr(local_chat, "_openai_agent", spy) + fake_model([[_msg_item("ok")]]) + client.chat("q", reasoning_effort="low") + assert seen["settings"].extra_args["reasoning_effort"] == "low" + fake_model([[_msg_item("ok")]]) + client.chat("q") + assert seen["settings"].extra_args is None + + def test_chat_cloud_unwraps_envelope(monkeypatch): cloud = PageIndexCloudClient(api_key="pi-test-key")