diff --git a/pageindex/__init__.py b/pageindex/__init__.py index 669f13629..0843c677e 100644 --- a/pageindex/__init__.py +++ b/pageindex/__init__.py @@ -3,6 +3,8 @@ from .client import PageIndexClient, PageIndexCloudClient, PageIndexLocalClient from .errors import PageIndexAPIError +from .types import (ChatConfig, CloudIndexConfig, IndexConfig, + LocalIndexConfig) if _TYPE_CHECKING: from .flash import page_index_flash @@ -13,6 +15,7 @@ __all__ = [ "PageIndexClient", "PageIndexCloudClient", "PageIndexLocalClient", "PageIndexAPIError", + "IndexConfig", "CloudIndexConfig", "LocalIndexConfig", "ChatConfig", "page_index", "page_index_main", "page_index_flash", "optimize_tree", "md_to_tree", ] @@ -25,7 +28,7 @@ _SUBMODULES = {"agent_tools", "client", "cloud_api", "errors", "flash", "integrations", "local_api", "local_chat", "local_store", "mcp_bridge", "page_index_classic", "page_index_md", - "tree_optimize", "utils"} + "tree_optimize", "types", "utils"} def __getattr__(name): diff --git a/pageindex/agent_tools.py b/pageindex/agent_tools.py index a15c1e808..412b8bb8d 100644 --- a/pageindex/agent_tools.py +++ b/pageindex/agent_tools.py @@ -1491,8 +1491,10 @@ def _require_local_scope(client, doc_ids) -> None: _require_doc_selection(doc_ids) if doc_ids is not None and getattr(client, "api_key", None): raise PageIndexAPIError( - "doc_ids scoping applies to local tools only — cloud calls " - "are scoped server-side." + "doc_ids scoping applies to local tools only — the managed " + "cloud chat scopes doc_id server-side, and own-model chat " + "over cloud documents targets documents at the prompt level, " + "without a tool-layer allowlist." ) @@ -1506,6 +1508,12 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None, if getattr(client, "api_key", None): bridge = _cloud_bridge(client, gated=not include_management) tools_meta = bridge.list_tools() + if not tools_meta: + raise PageIndexAPIError( + "The MCP server returned no tools — a zero-tool agent would " + "answer from the model's own knowledge, not the documents, " + "with nothing to signal it." + ) return [(str(meta.get("name") or "tool"), meta.get("description") or "", copy.deepcopy(meta.get("inputSchema")) diff --git a/pageindex/client.py b/pageindex/client.py index c0d8231b9..2fc4b2c79 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -58,30 +58,246 @@ def _agents_sdk_model_name(model: str) -> str: return f"litellm/{model}" +_LOCAL_INDEX_KEYS = ("model", "summary_model", "backend", "storage_path") + +# Near-synonyms of "cloud" that would otherwise parse as model names — +# a silent wrong mode. They error, pointing at the real word. +_RESERVED_MODE_WORDS = {"hosted", "managed"} + + +def _env_cloud_key(spelling: str, inline: str = "api_key=...") -> str: + # .env support lives in utils' import-time load_dotenv(): load it + # before the read, or a key in .env is visible only by import order. + from . import utils # noqa: F401 + key = os.environ.get("PAGEINDEX_API_KEY") + if not key: + raise PageIndexAPIError( + f"{spelling} reads the PageIndex API key from the " + "PAGEINDEX_API_KEY environment variable, which is not set — " + f"export it, or pass the key inline ({inline}).") + return key + + +# One argument vocabulary regardless of spelling: every value is shape- +# checked in the constructor, so a wrong type or an empty value refuses +# there as a PageIndexAPIError — never later, never silently. +_ARG_TYPES: "dict[str, tuple[type, ...]]" = { + "model": (str,), "index_model": (str,), "summary_model": (str,), + "chat_model": (str,), "retrieve_model": (str,), + "storage_path": (str, os.PathLike), "index_backend": (dict,), + "chat_backend": (dict,)} + + +def _declared_mode(value, side: str): + if isinstance(value, str): + value = value.strip().lower() + if value not in (None, "cloud", "local"): + raise PageIndexAPIError( + f'{side} "mode" must be "cloud" or "local", not {value!r}.') + return value + + +_CloudKey = Union[str, Callable[[], str], None] + + +def _resolve_index_slot(index) -> "tuple[_CloudKey, dict[str, Any]]": + """The ``index=`` slot as (cloud api_key, local overrides). A dict + declares its side by its keys; an optional "mode" states it and must + agree. Keyless cloud spellings ("cloud" / "pageindex-cloud", + {"mode": "cloud"}) return the environment read as a thunk, so the + caller's mode cross-check runs before the environment is touched.""" + from .types import PAGEINDEX_CLOUD + if isinstance(index, str): + # Normalized compare: a case/whitespace variant of a mode word + # must never fall through and silently become a model name. + word = index.strip().lower() + if word in (PAGEINDEX_CLOUD, "cloud"): + return lambda: _env_cloud_key(f'index="{index.strip()}"', + 'index={"api_key": ...}'), {} + if word == "local": + return None, {} + if word in _RESERVED_MODE_WORDS: + raise PageIndexAPIError( + f'index="{index}" is not a mode word — the cloud spelling ' + 'is index="cloud" (key from PAGEINDEX_API_KEY) or ' + 'index={"api_key": ...}.') + if index.strip(): + return None, {"index_model": index} + raise PageIndexAPIError( + "index is an empty string — pass a local index model name, " + 'or "cloud".') + if isinstance(index, dict): + # None-valued keys mean "absent", exactly like the flat arguments. + conf = {name: value for name, value in index.items() + if value is not None} + declared = _declared_mode(conf.pop("mode", None), "index") + if not conf: + if declared == "cloud": + return lambda: _env_cloud_key('index={"mode": "cloud"}', + 'index={"api_key": ...}'), {} + if declared == "local": + return None, {} + raise PageIndexAPIError( + "index is an empty dict — its keys pick the side: " + '{"api_key": ...} for cloud documents, or ' + f"{', '.join(_LOCAL_INDEX_KEYS)} for the local store.") + unknown = set(conf) - {"api_key"} - set(_LOCAL_INDEX_KEYS) + if unknown: + raise PageIndexAPIError( + f"Unknown index keys ({', '.join(sorted(unknown))}) — " + 'cloud takes "api_key"; local takes ' + f"{', '.join(_LOCAL_INDEX_KEYS)}.") + if "api_key" in conf: + if declared == "local": + raise PageIndexAPIError( + 'index declares mode "local" but carries api_key — ' + "an API key means cloud documents. Drop one of them.") + if len(conf) > 1: + raise PageIndexAPIError( + "index mixes cloud and local keys — cloud documents " + 'take {"api_key": ...} alone; the cloud pipeline does ' + "its own indexing.") + key = conf["api_key"] + if not key or not isinstance(key, str): + raise PageIndexAPIError( + 'index["api_key"] must be a non-empty string.') + return key, {} + if declared == "cloud": + raise PageIndexAPIError( + 'index declares mode "cloud" but carries local keys ' + f"({', '.join(sorted(conf))}) — the cloud pipeline does " + 'its own indexing; cloud takes "api_key" only.') + mapped = {"index_model": conf.get("model"), + "summary_model": conf.get("summary_model"), + "index_backend": conf.get("backend"), + "storage_path": conf.get("storage_path")} + return None, {name: value for name, value in mapped.items() + if value is not None} + raise PageIndexAPIError("index must be a string or a dict.") + + +def _resolve_chat_slot(chat) -> "tuple[Optional[str], dict[str, Any]]": + """The ``chat=`` slot as (mode, own-model overrides) — mode is + "managed", "own", or None (nothing declared beyond the overrides).""" + from .types import PAGEINDEX_CLOUD + if isinstance(chat, str): + word = chat.strip().lower() + if word in (PAGEINDEX_CLOUD, "cloud"): + return "managed", {} + if word == "local": + return "own", {} + if word in _RESERVED_MODE_WORDS: + raise PageIndexAPIError( + f'chat="{chat}" is not a mode word — the managed chat is ' + 'chat="cloud".') + if chat.strip(): + return "own", {"chat_model": chat} + raise PageIndexAPIError( + "chat is an empty string — pass a model name, or " + '"cloud" for the managed chat.') + if isinstance(chat, dict): + # None-valued keys mean "absent", exactly like the flat arguments. + conf = {name: value for name, value in chat.items() + if value is not None} + declared = _declared_mode(conf.pop("mode", None), "chat") + unknown = set(conf) - {"model", "backend"} + if (not conf and declared is None) or unknown: + raise PageIndexAPIError( + ("chat is an empty dict" if not conf else + f"Unknown chat keys ({', '.join(sorted(unknown))})") + + ' — chat takes "model" and "backend" (your own model), ' + 'or {"mode": "cloud"} / "cloud" for the managed chat.') + if declared == "cloud": + if conf: + raise PageIndexAPIError( + 'chat declares mode "cloud" but carries ' + f"({', '.join(sorted(conf))}) — the managed chat " + "selects its own model. Drop the mode, or the keys.") + return "managed", {} + mapped = {"chat_model": conf.get("model"), + "chat_backend": conf.get("backend")} + return "own", {name: value for name, value in mapped.items() + if value is not None} + raise PageIndexAPIError("chat must be a string or a dict.") + + class PageIndexClient: """ Python SDK client for PageIndex. - Cloud mode (an ``api_key`` is given) talks to the PageIndex API at - api.pageindex.ai, exactly like the 0.2.x SDK. Local mode (no ``api_key``) - runs the same operations on your machine: documents are indexed with the - open-source PageIndex pipeline using your own LLM provider key (e.g. - ``OPENAI_API_KEY`` in the environment) and stored under ``storage_path``. + Two independent sides, each locally run or cloud-managed: + + - **index** — where documents live. With an ``api_key`` they live in + your PageIndex cloud account, indexed by the managed pipeline, + exactly like the 0.2.x SDK. Without one they are indexed on your + machine by the open-source pipeline (your own LLM provider key, + e.g. ``OPENAI_API_KEY``) and stored under ``storage_path``. + - **chat** — who answers. With a chat model configured + (``chat_model=`` / ``chat=``), the document-QA agent runs in your + process against your own model and credentials — in both index + modes. On a cloud client with no chat model, the managed cloud + chat answers. + + ``api_key`` moves your documents, never your model: ``chat_model`` + always means your own model on your own keys. The fourth combination + (local documents + managed chat) cannot be expressed — the managed + chat cannot read your disk. + + Usage: + client = PageIndexClient() # local docs + your model + client = PageIndexClient(api_key="...") # cloud docs + managed chat + client = PageIndexClient(api_key="...", # cloud docs + your model + chat_model="openai/gpt-5.2") + + ``index=`` / ``chat=`` are the grouped spelling of the same flat + arguments — a string as shorthand, a dict for the full config; each + side picks one spelling per client. ``index="cloud"`` (or the label + ``"pageindex-cloud"``) is the keyless cloud spelling (the key comes + from the ``PAGEINDEX_API_KEY`` environment variable — which is read + only when the code explicitly says cloud; a bare ``PageIndexClient()`` + stays local regardless of the environment). Args: api_key (str, optional): PageIndex cloud API key (https://dash.pageindex.ai/api-keys). Omit for local mode. + index (str | dict, optional): The index side, grouped — + ``"cloud"`` / ``"pageindex-cloud"`` (cloud, key from the + environment), ``"local"``, a local index model name, or a + dict: ``{"api_key": ...}`` for cloud, ``{"model", + "summary_model", "backend", "storage_path"}`` for local. An + optional ``"mode"`` key (``"cloud"`` / ``"local"``) states + the side and must agree with the other keys; ``{"mode": + "cloud"}`` alone reads the key from the environment. Not + combinable with this side's flat arguments. + chat (str | dict, optional): The chat side, grouped — a model + name (your own model), ``"cloud"`` / ``"pageindex-cloud"`` + (managed chat, cloud clients only), ``"local"`` (your own + model, the default one), or ``{"model", "backend"}``. An + optional ``"mode"`` key states the side: ``{"mode": + "cloud"}`` alone is the managed chat, ``"local"`` is your + own model and must agree with the other keys. Not + combinable with this side's flat arguments. + mode (str, optional): Client-level declaration of where the + documents live — ``"cloud"`` or ``"local"`` — checked + against the other arguments (``mode="local"`` with an + api_key errors). ``mode="cloud"`` alone reads the key from + the ``PAGEINDEX_API_KEY`` environment variable. Always + optional: the arguments themselves already carry the mode. index_model (str, optional): Local mode only — LLM used to index documents (structure and summaries). Defaults to the SDK default (fast and cheap). - chat_model (str, optional): Local mode only — the model the chat - surfaces (``chat``, ``chat_completions``, ``responses``) - default to, exposed as ``client.chat_model``. Chat names - route through LiteLLM and mean what LiteLLM says they mean; - bare names are OpenAI-compatible shorthand, and - ``openai/Qwen/...`` is the form for an OpenAI-compatible - server that itself serves slashed model ids (vLLM, TGI). - Defaults to the SDK default (strong). + chat_model (str, optional): Your own model for the chat surfaces + (``chat``, ``chat_completions``, ``responses``), exposed as + ``client.chat_model`` — on a cloud client, setting it runs + the document-QA agent in your process over the cloud + documents (page content then flows through your process to + your model provider). Chat names route through LiteLLM and + mean what LiteLLM says they mean; bare names are + OpenAI-compatible shorthand, and ``openai/Qwen/...`` is the + form for an OpenAI-compatible server that itself serves + slashed model ids (vLLM, TGI). Defaults to the SDK default + (strong); reads ``None`` on a cloud client where the managed + chat answers. model (str, optional): Local mode only — one model for both roles: sets the default for ``index_model`` and ``chat_model`` at once. The role-specific arguments win over it. (Also the @@ -90,26 +306,23 @@ class PageIndexClient: summary_model (str, optional): Local mode only — legacy: overrides the model used for node summaries and document descriptions; ``index_model`` covers this. - retrieve_model (str, optional): Local mode only — legacy name for - ``chat_model``. + retrieve_model (str, optional): Legacy name for ``chat_model`` — + same meaning everywhere, cloud clients included. storage_path (str, optional): Local mode only — directory where indexed documents are stored. Defaults to ``./.pageindex``. index_backend (dict, optional): Local mode only — connection overrides for the indexing lane's LLM calls. Keys are LiteLLM's own connection params — ``api_key``, ``api_base``, ``api_version``, ``aws_*``, … — passed through verbatim. - chat_backend (dict, optional): Local mode only — default - connection overrides for the chat surfaces; a call's own + chat_backend (dict, optional): Default connection overrides for + the chat surfaces — a chat-side argument like ``chat_model``, + so on a cloud client it selects own-model chat. A call's own ``backend`` keys win over it. The dict reaches whichever door runs, in that door's vocabulary (see each method) — ``api_key`` / ``base_url`` mean the same thing on all three. - Usage: - client = PageIndexClient(api_key="...") # cloud - client = PageIndexClient() # local - - PageIndexCloudClient / PageIndexLocalClient pin the mode at construction - instead of inferring it from api_key. + PageIndexCloudClient / PageIndexLocalClient pin the index side at + construction instead of inferring it from api_key. Local mode differences (all documented per method): indexing is synchronous, only PDFs are supported, and folders / ``beta_headers`` / @@ -119,10 +332,15 @@ class PageIndexClient: BASE_URL = "https://api.pageindex.ai" + _pin: Optional[str] = None # the pinned subclasses' index side + def __init__( self, api_key: Optional[str] = None, *, + index: Optional[Union[dict[str, Any], str]] = None, + chat: Optional[Union[dict[str, Any], str]] = None, + mode: Optional[str] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, model: Optional[str] = None, @@ -137,45 +355,171 @@ def __init__( "api_key is an empty string. Pass a real PageIndex API key for " "cloud mode, or omit api_key entirely for local mode." ) - model_args = {"index_model": index_model, "chat_model": chat_model, - "model": model, "summary_model": summary_model, - "retrieve_model": retrieve_model} - if api_key is not None: - local_only = dict(model_args, storage_path=storage_path, - index_backend=index_backend, - chat_backend=chat_backend) - passed = [name for name, value in local_only.items() if value is not None] - if passed: + # Each side picks one spelling — its slot, or the flat arguments. + # ``model`` sets every role, so it claims both sides. + index_flat: dict[str, Any] = { + name: value for name, value in + (("api_key", api_key), + ("index_model", index_model), + ("summary_model", summary_model), + ("index_backend", index_backend), + ("storage_path", storage_path), ("model", model)) + if value is not None} + chat_flat: dict[str, Any] = { + name: value for name, value in + (("chat_model", chat_model), + ("retrieve_model", retrieve_model), + ("chat_backend", chat_backend), ("model", model)) + if value is not None} + if model is not None and (index is not None or chat is not None): + raise PageIndexAPIError( + "model= sets both roles at once, so no slot can absorb " + 'it — name the model inside the slot ({"model": ...}) ' + "and use index_model= / chat_model= for a side written " + "flat.") + if index is not None and index_flat: + raise PageIndexAPIError( + "index= and the flat index-side arguments " + f"({', '.join(sorted(index_flat))}) are two spellings of " + "the same thing — use one or the other.") + if chat is not None and chat_flat: + raise PageIndexAPIError( + "chat= and the flat chat-side arguments " + f"({', '.join(sorted(chat_flat))}) are two spellings of " + "the same thing — use one or the other.") + # ``mode=`` is a cross-check, not a spelling: it combines with + # either spelling of the index side and must agree with it. The + # pinned classes declare the side by class; their errors name the + # class, never a mode= the user did not write. + declared = _declared_mode(mode, "client") or self._pin + pinned = type(self).__name__ if self._pin else None + if index is not None: + cloud_key, index_conf = _resolve_index_slot(index) + if declared == "local" and cloud_key is not None: raise PageIndexAPIError( - f"Local-mode arguments ({', '.join(passed)}) cannot be " - "combined with api_key — remove them, or omit api_key to " - "run locally." - ) - self.api_key = api_key + f"{pinned} pins local documents — that index= selects " + "cloud documents. Drop it, or use PageIndexCloudClient." + if pinned else + 'mode="local" disagrees with index= — that index ' + "selects cloud documents. Drop one of them.") + if declared == "cloud" and cloud_key is None: + raise PageIndexAPIError( + f"{pinned} pins cloud documents — that index= " + "configures the local store. Drop it, or use " + "PageIndexLocalClient." + if pinned else + 'mode="cloud" disagrees with index= — that index ' + "configures the local store. Drop one of them.") + if callable(cloud_key): + cloud_key = cloud_key() + else: + cloud_key = api_key + if declared == "local" and api_key is not None: + raise PageIndexAPIError( + 'mode="local" conflicts with api_key — an API key ' + "means cloud documents. Drop one of them.") + if declared == "cloud" and cloud_key is None: + cloud_key = _env_cloud_key('mode="cloud"') + index_conf = {name: value for name, value in index_flat.items() + if name != "api_key"} + if chat is not None: + chat_mode, chat_conf = _resolve_chat_slot(chat) + else: + chat_mode = "own" if chat_flat else None + chat_conf = chat_flat + # Every spelling lands here: strings are stripped, wrong types and + # empty values refuse loudly — an empty chat-side value must never + # silently select own-model chat. + for side, slot, conf in (("index", index, index_conf), + ("chat", chat, chat_conf)): + for name, value in conf.items(): + # Slot keys are the flat names with the side prefix off. + shown = (f'{side}["{name.removeprefix(side + "_")}"]' + if slot is not None else name) + if not isinstance(value, _ARG_TYPES[name]): + raise PageIndexAPIError( + f"{shown} must be a {_ARG_TYPES[name][0].__name__}, " + f"got {type(value).__name__}.") + if isinstance(value, str): + value = conf[name] = value.strip() + if not value: + raise PageIndexAPIError( + f"{shown} is empty — it configures nothing. Pass a " + "real value, or drop the argument.") + + if cloud_key is not None: + if index_conf: + raise PageIndexAPIError( + "Cloud documents are indexed by the PageIndex pipeline " + "— the index-side arguments " + f"({', '.join(sorted(index_conf))}) have nothing to " + "configure there; remove them. (chat_model= / chat= " + "stay yours: they run the chat agent in your process " + "with your own model.)") + self.api_key = cloud_key from .cloud_api import CloudAPI self._api = CloudAPI(self) + if chat_mode == "own": + from .utils import ConfigLoader + overrides = {name: value for name, value in chat_conf.items() + if name in ("chat_model", "retrieve_model") + and value} + opt = ConfigLoader().load(overrides or None) + self.chat_model = opt.chat_model + self.chat_backend = chat_conf.get("chat_backend") + _preload_litellm() + else: + # Managed chat: the endpoint selects its own model. + self.chat_model = None + self.chat_backend = None else: + if chat_mode == "managed": + if pinned: + exits = (f"{pinned} pins local documents: use " + "PageIndexCloudClient (or PageIndexClient(" + "api_key=...))") + else: + exits = ('Go cloud (api_key=... or index="cloud")' + + (' and drop mode="local"' if mode is not None + else "")) + raise PageIndexAPIError( + "The managed chat needs cloud documents — it cannot " + f"read the local store. {exits}, or set your own chat " + "model instead.") from .utils import ConfigLoader - overrides = {key: value for key, value in model_args.items() - if value} + overrides = {name: value for name, value in + {**index_conf, **chat_conf}.items() + if name in ("model", "index_model", "summary_model", + "chat_model", "retrieve_model") + and value} opt = ConfigLoader().load(overrides or None) self.model = opt.model self.index_model = opt.index_model self.summary_model = opt.summary_model self.chat_model = opt.chat_model - self.chat_backend = chat_backend - self.storage_path = storage_path or ".pageindex" + self.chat_backend = chat_conf.get("chat_backend") + self.storage_path = index_conf.get("storage_path") or ".pageindex" from .local_api import LocalAPI self._api = LocalAPI( storage_path=self.storage_path, model=self.model, summary_model=self.summary_model, - index_backend=index_backend, + index_backend=index_conf.get("index_backend"), ) # LiteLLM's multi-second import would otherwise land on the # first chat call; failures resurface there with real context. _preload_litellm() + @property + def _local_chat(self) -> bool: + # Derived, never stored: own-model chat is exactly "a chat model + # is configured" (None on a managed-chat client). Blank configures + # nothing — the constructor refuses it, and assignment must agree. + model = getattr(self, "chat_model", None) + if isinstance(model, str): + return bool(model.strip()) + return model is not None + @property def retrieve_model(self): """Legacy name for ``chat_model``.""" @@ -415,7 +759,7 @@ def chat( """ Ask a question about your documents, get the answer. - Thin sugar over ``chat_completions()`` in both modes — same + Thin sugar over ``chat_completions()`` in every mode — same engine, same wire, minus the envelope. Multi-turn: keep your own role/content list of the visible conversation (append each answer as an assistant message) and pass it back. For usage accounting, @@ -426,14 +770,17 @@ def chat( messages: A question string, or role/content conversation history. doc_id: Document ID or list of IDs to scope the conversation. - Keep it identical across a conversation's calls. + Keep it identical across a conversation's calls. Local + documents: also enforced at the tool layer, not just + prompted. Cloud documents: the managed chat scopes + server-side; own-model chat targets at the prompt level. 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. + model: Own-model chat only — backend model name (defaults + to ``chat_model``). + reasoning_effort: Own-model chat 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 @@ -472,9 +819,13 @@ def chat_completions( """ PageIndex Chat Completions: document QA in one call. - Cloud: the hosted chat endpoint. Local: a managed document-QA agent - run over the local tools against your own LLM backend, routed - through LiteLLM — model names mean what LiteLLM says they mean. + With no chat model configured (a plain cloud client): the managed + hosted chat endpoint. With one — local mode, or a cloud client + constructed with ``chat_model=``/``chat=`` (own-model chat) — a + managed document-QA agent runs in your process over the mode's + tools (local store, or the live cloud tool set) against your own + LLM backend, routed through LiteLLM — model names mean what + LiteLLM says they mean. Bare names are OpenAI-compatible shorthand (the OpenAI SDK's usual env config — OPENAI_API_KEY, OPENAI_BASE_URL — selects the backend, so any OpenAI-compatible server works; write @@ -493,46 +844,50 @@ def chat_completions( Args: messages: Conversation messages with 'role' and 'content' keys, or a bare query string (it becomes a single user message). - Local also accepts system/developer messages — their content - is appended to the managed system prompt. Local takes text - history only: tool-role turns are rejected (the cloud - endpoint forwards them verbatim), and message fields beyond - role/content are dropped. + Own-model chat also accepts system/developer messages — + their content is appended to the managed system prompt — + and takes text history only: tool-role turns are rejected + (the managed endpoint forwards them verbatim), and message + fields beyond role/content are dropped. stream: Enable streaming responses. doc_id: Document ID or list of IDs to scope the conversation. Keep it identical across a conversation's calls — the targeting block it adds is re-set each call and is part - of the cached prompt prefix. + of the cached prompt prefix. Local documents: also + enforced at the tool layer, not just prompted. Cloud + documents: the managed chat scopes server-side; + own-model chat targets at the prompt level. temperature: Sampling temperature, passed through to the model. stream_metadata: With stream=True, yield chunk dicts instead of text pieces. - enable_citations: Cloud-only — local mode raises (citations need - block-level OCR data local mode does not store). - model: Local only — backend model name (defaults to - ``chat_model``). The cloud endpoint selects its own. - max_turns: Local only — cap on agent turns per call. - top_p: Local only — nucleus sampling, passed through to the - model. - max_tokens: Local only — per-call output cap, passed through; - it bounds each backend call in the agent loop (the way - max_turns bounds the loop), not the whole run. - reasoning_effort: Local only — passed through verbatim as + enable_citations: Managed chat only — own-model chat raises + (the in-process engine has no citation machinery). + model: Own-model chat only — backend model name (defaults to + ``chat_model``). The managed endpoint selects its own. + max_turns: Own-model chat only — cap on agent turns per call. + top_p: Own-model chat only — nucleus sampling, passed + through to the model. + max_tokens: Own-model chat only — per-call output cap, + passed through; it bounds each backend call in the agent + loop (the way max_turns bounds the loop), not the whole + run. + reasoning_effort: Own-model chat only — passed through verbatim as LiteLLM's ``reasoning_effort``; each provider maps it to its own thinking control, and the values mean what the backend says they mean. Unset sends nothing (the backend's default applies). - extra_body: Local only — extra request fields beyond this + extra_body: Own-model chat only — extra request fields beyond this method's parameters, merged last so they win. OpenAI-compatible backends take them verbatim in the request body; LiteLLM-routed providers take them as LiteLLM's own params (mapped or refused per provider). Credentials belong in ``backend``, never here. - extra_headers: Local only — extra HTTP headers merged into + extra_headers: Own-model chat only — extra HTTP headers merged into each backend request; caller headers win. One exception: LiteLLM's anthropic adapter owns the ``anthropic-beta`` header (your value is dropped there) — use ``messages()`` for Anthropic beta flags. - backend: Local only — connection overrides for this call's + backend: Own-model chat only — connection overrides for this call's backend, merged over the client's ``chat_backend`` (per-call keys win). Keys are LiteLLM's own connection params — ``api_key``, ``base_url``, ``api_version``, @@ -550,8 +905,7 @@ def chat_completions( "messages must be a non-empty string or a list of " "message dicts.") messages = [{"role": "user", "content": messages}] - from .cloud_api import CloudAPI - if not isinstance(self._api, CloudAPI): + if self._local_chat: from .local_chat import run_chat_completions return run_chat_completions( self, messages, stream=stream, doc_id=doc_id, @@ -567,10 +921,14 @@ def chat_completions( or backend is not None): raise PageIndexAPIError( "model, max_turns, top_p, max_tokens, reasoning_effort, " - "extra_body, extra_headers and backend are local-mode " - "parameters — the cloud chat endpoint selects its own model." + "extra_body, extra_headers and backend drive your own chat " + "model, which this client does not configure — construct " + "the client with chat_model=... (or a chat= model) to run the " + "agent in your process, or drop them to use the managed " + "chat endpoint, which selects its own model." ) - return self._api.chat_completions( + from .cloud_api import CloudAPI + return cast(CloudAPI, self._api).chat_completions( messages=messages, stream=stream, doc_id=doc_id, temperature=temperature, stream_metadata=stream_metadata, enable_citations=enable_citations, @@ -595,11 +953,13 @@ def responses( """ Document QA over the OpenAI Responses protocol — the agentic surface. - Local only for now. Drives your backend's /responses end to end (no - translation layer). The envelope is official Responses shape — - ``output`` carries the model-produced items and parses with the - openai SDK types — and the whole process transcript (including the - tool outputs the SDK executed) rides in the extra ``items`` field. + Own-model chat only — local mode, or a cloud client constructed + with ``chat_model=``/``chat=``. Drives your backend's /responses + end to end (no translation layer). The envelope is official + Responses shape — ``output`` carries the model-produced items + and parses with the openai SDK types — and the whole process + transcript (including the tool outputs the SDK executed) rides + in the extra ``items`` field. Append the returned ``items`` to your next call's ``input`` verbatim to keep provider prompt-cache prefix continuity and the agent's memory of what it already read. @@ -626,7 +986,9 @@ def responses( doc_id: Document ID or list of IDs to scope the conversation. Keep it identical across a conversation's calls — the targeting block it adds is re-set each call and is part - of the cached prompt prefix. + of the cached prompt prefix. Local documents: also + enforced at the tool layer; cloud documents: + prompt-level targeting only. instructions: Appended to the managed system prompt. temperature / top_p: Passed through to the model. max_turns: Cap on agent turns per call. @@ -650,11 +1012,11 @@ def responses( ``api_key``, ``base_url``, ``organization``, … — passed verbatim; unknown keys raise. """ - from .cloud_api import CloudAPI - if isinstance(self._api, CloudAPI): + if not self._local_chat: raise PageIndexAPIError( - "responses is not available on PageIndex cloud yet — it is " - "a local-mode surface for now." + "responses() drives your own chat model — construct the " + "client with chat_model=... (or a chat= model); the managed " + "cloud chat serves chat_completions() only." ) from .local_chat import run_responses return run_responses( @@ -686,10 +1048,12 @@ def messages( """ Document QA over the Anthropic Messages protocol — Claude-native. - Local only for now. Drives Anthropic's /v1/messages via the - Anthropic SDK's own tool runner (requires ``pageindex[anthropic]``; - ANTHROPIC_API_KEY selects the backend). ``tool_use``/``tool_result`` - round-trip is the format's native behavior: the response is the + Own-model chat only — local mode, or a cloud client constructed + with ``chat_model=``/``chat=``. Drives Anthropic's /v1/messages + via the Anthropic SDK's own tool runner (requires + ``pageindex[anthropic]``; ANTHROPIC_API_KEY selects the + backend). ``tool_use``/``tool_result`` round-trip is the + format's native behavior: the response is the final message envelope with cross-turn aggregated ``usage`` plus a ``messages`` field — the full new turn sequence, valid for verbatim append to your history. The managed system prompt carries a @@ -714,7 +1078,9 @@ def messages( convenience events), one message sequence per turn. doc_id: Document ID or list of IDs to scope the conversation. Keep it identical across a conversation's calls — the - targeting block it adds is re-set each call. + targeting block it adds is re-set each call. Local + documents: also enforced at the tool layer; cloud + documents: prompt-level targeting only. system: Appended after the managed system blocks. temperature / top_p / top_k / stop_sequences: Passed through. max_turns: Cap on agent turns per call (default 10, like the @@ -736,11 +1102,11 @@ def messages( ``api_key``, ``base_url``, ``auth_token``, … — passed verbatim; unknown keys raise. """ - from .cloud_api import CloudAPI - if isinstance(self._api, CloudAPI): + if not self._local_chat: raise PageIndexAPIError( - "messages is not available on PageIndex cloud yet — it is " - "a local-mode surface for now." + "messages() drives your own chat model — construct the " + "client with chat_model=... (or a chat= model); the managed " + "cloud chat serves chat_completions() only." ) from .local_chat import run_messages return run_messages( @@ -907,8 +1273,9 @@ def openai_agent_config( Sugar over the explicit form — ``agent_instructions`` (with ``doc_id`` targeting) as the instructions and - ``as_openai_tools`` as the tools; local clients also carry their - configured ``chat_model`` (cloud omits ``model`` so the + ``as_openai_tools`` as the tools; clients with a configured + ``chat_model`` — local mode, or cloud with ``chat_model=`` — + also carry it (a plain cloud client omits ``model`` so the framework default applies). To customize further, switch to those methods directly. You run this config in your own environment, so its model auth comes from there — @@ -946,7 +1313,7 @@ def openai_agent_config( include_management=include_management), "tools": self.as_openai_tools(include_management, doc_id=scope), } - model = model or getattr(self, "chat_model", None) + model = model or (self.chat_model if self._local_chat else None) if model: config["model"] = _agents_sdk_model_name(model) if config["model"].startswith("litellm/"): @@ -1235,23 +1602,48 @@ def _require_cloud(self, message: str): class PageIndexCloudClient(PageIndexClient): - """Cloud mode — requires a real API key at construction.""" + """Cloud mode — the class name says cloud, so the key may come from + the environment: ``PageIndexCloudClient()`` reads PAGEINDEX_API_KEY. + The shortest env-key cloud spelling.""" - def __init__(self, api_key: str): - if not api_key: - raise PageIndexAPIError( - "PageIndexCloudClient requires a PageIndex API key — get one " - "at https://dash.pageindex.ai/api-keys." - ) - super().__init__(api_key) + _pin = "cloud" + + def __init__( + self, + api_key: Optional[str] = None, + *, + index: Optional[Union[dict[str, Any], str]] = None, + chat: Optional[Union[dict[str, Any], str]] = None, + chat_model: Optional[str] = None, + retrieve_model: Optional[str] = None, + chat_backend: Optional[dict[str, Any]] = None, + ): + if index is None: + if api_key is None: + # .env keys arrive via utils' import-time load_dotenv(). + from . import utils # noqa: F401 + api_key = os.environ.get("PAGEINDEX_API_KEY") + if not api_key: + raise PageIndexAPIError( + "PageIndexCloudClient requires a PageIndex API key — " + "pass api_key=..., or export PAGEINDEX_API_KEY. Get one " + "at https://dash.pageindex.ai/api-keys." + ) + super().__init__(api_key, index=index, chat=chat, + chat_model=chat_model, retrieve_model=retrieve_model, + chat_backend=chat_backend) class PageIndexLocalClient(PageIndexClient): """Local mode — no api_key parameter, no cloud access.""" + _pin = "local" + def __init__( self, *, + index: Optional[Union[dict[str, Any], str]] = None, + chat: Optional[Union[dict[str, Any], str]] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, model: Optional[str] = None, @@ -1261,7 +1653,8 @@ def __init__( index_backend: Optional[dict[str, Any]] = None, chat_backend: Optional[dict[str, Any]] = None, ): - super().__init__(None, index_model=index_model, chat_model=chat_model, + super().__init__(None, index=index, chat=chat, + index_model=index_model, chat_model=chat_model, model=model, summary_model=summary_model, retrieve_model=retrieve_model, storage_path=storage_path, index_backend=index_backend, chat_backend=chat_backend) diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index c5bcaa7c5..b4432a1ed 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -10,7 +10,7 @@ import uuid from typing import Any, Iterator, Optional, Union -from .agent_tools import AGENT_INSTRUCTIONS, doc_targeting_block +from .agent_tools import _base_instructions, doc_targeting_block from .errors import PageIndexAPIError CHAT_HEADER = ( @@ -21,20 +21,24 @@ # ── shared: prompt, doc targeting, validation, sync bridges ── -def _managed_instructions(extra_system: list[str]) -> str: - return "\n\n".join([CHAT_HEADER, AGENT_INSTRUCTIONS, *extra_system]) +def _managed_instructions(client, extra_system: list[str]) -> str: + # Local: the built-in subset guidance. Own-model chat over cloud + # documents: the live instructions the MCP server serves. + base: str = _base_instructions(client) + return "\n\n".join([CHAT_HEADER, base, *extra_system]) -def _doc_block(client, doc_id) -> Optional[str]: +def _doc_block(client, doc_id, scoped: bool) -> Optional[str]: if doc_id is None: return None if not isinstance(doc_id, (str, list)): raise PageIndexAPIError("doc_id must be a string or a list of " "strings.") - # scoped: the chat surfaces also pass doc_id into the tool layer, so - # name resolution happens inside the allowlist — only a duplicate name - # within the targeted set shadows. - return doc_targeting_block(client, doc_id, scoped=True) + # scoped: local surfaces also pass doc_id into the tool layer, so name + # resolution happens inside the allowlist — only a duplicate name + # within the targeted set shadows. Cloud tools take no allowlist + # (targeting is prompt-level), so the whole library shadows. + return doc_targeting_block(client, doc_id, scoped=scoped) def _system_text(content: Any) -> str: @@ -166,7 +170,8 @@ def _require_openai_agents(method: str) -> None: import agents # noqa: F401 except ImportError as exc: raise PageIndexAPIError( - f"{method} in local mode requires the OpenAI Agents SDK — " + f"{method} with your own chat model requires the OpenAI " + "Agents SDK — " "pip install openai-agents. " "messages() runs on the anthropic extra instead." ) from exc @@ -375,13 +380,16 @@ def _conversation_cache_key(model_name: str, instructions: str, doc_id, return "pageindex-" + hashlib.sha256(seed.encode()).hexdigest()[:16] -def _model_backend_error(exc, lane: str) -> PageIndexAPIError: +def _model_backend_error(exc, lane: str, client=None) -> PageIndexAPIError: """Wrap a provider failure; the sol-class refusal (chatcmpl rejects function tools while reasoning is on) gets its documented exits appended, since the fix is a different route, not a retry. The exits are per-lane: of the chat lane's three, two are dead ends for a responses() caller — it IS the other lane, and its reasoning knob is - ``reasoning``, not ``reasoning_effort``.""" + ``reasoning``, not ``reasoning_effort``. On a cloud client an + auth-shaped failure gets the own-model architecture spelled out — + the misreading it corrects ("the cloud runs my model") surfaces + exactly here.""" message = f"The model backend failed: {exc}" if "Function tools with reasoning_effort" in str(exc): message += ( @@ -393,17 +401,28 @@ def _model_backend_error(exc, lane: str) -> PageIndexAPIError: "efforts), or call responses() instead." if lane == "chat" else "." ) + if (getattr(client, "api_key", None) + and (getattr(exc, "status_code", None) == 401 + or "api key" in str(exc).lower().replace("_", " "))): + message += ( + " — note: your chat model runs in your process on your own " + "provider credentials; the PageIndex api_key does not cover " + "it. Set the provider key (or chat_backend)") + message += ( + ", or drop the chat model configuration to use the managed " + "cloud chat." if lane == "chat" else "." + ) return PageIndexAPIError(message) -def _translate_run_error(exc, max_turns, lane) -> PageIndexAPIError: +def _translate_run_error(exc, max_turns, lane, client=None) -> PageIndexAPIError: """The uncaught-run ladder every agent door shares.""" from agents.exceptions import AgentsException, MaxTurnsExceeded if isinstance(exc, MaxTurnsExceeded): return _wrap_max_turns(max_turns) if isinstance(exc, AgentsException): return PageIndexAPIError(f"The agent backend failed: {exc}") - return _model_backend_error(exc, lane) + return _model_backend_error(exc, lane, client) def _run_kwargs(max_turns) -> dict: @@ -575,19 +594,22 @@ def run_chat_completions(client, messages, stream: bool = False, ) -> Union[dict, Iterator[str], Iterator[dict]]: if enable_citations: raise PageIndexAPIError( - "enable_citations is cloud-only — citations need block-level OCR " - "data that local mode does not store." - ) + "enable_citations needs the managed chat endpoint — " + + ("drop the chat model configuration to use it." + if getattr(client, "api_key", None) else + "local mode does not store the block-level OCR data " + "citations need.")) _require_openai_agents("chat_completions") _validate_max_turns(max_turns) system_texts, history = _split_chat_messages(messages) - block = _doc_block(client, doc_id) + scope = client._local_doc_scope(doc_id) + block = _doc_block(client, doc_id, scoped=scope is not None) items = ([{"role": "user", "content": block}] if block else []) + history model_name = model or client.chat_model reported_model = _reported_model(model_name) - managed = _managed_instructions(system_texts) + managed = _managed_instructions(client, system_texts) agent = _openai_agent(client, "chat", model_name, managed, - temperature, top_p, doc_ids=doc_id, + temperature, top_p, doc_ids=scope, cache_key=_conversation_cache_key( model_name, managed, doc_id, history), reasoning_effort=reasoning_effort, @@ -606,7 +628,7 @@ def run_chat_completions(client, messages, stream: bool = False, Runner.run(agent, input=items, **run_kwargs))) except (MaxTurnsExceeded, AgentsException, openai.OpenAIError) as exc: - raise _translate_run_error(exc, max_turns, "chat") from exc + raise _translate_run_error(exc, max_turns, "chat", client) from exc return { "id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion", @@ -647,7 +669,7 @@ async def agen(): completed = True except (MaxTurnsExceeded, AgentsException, openai.OpenAIError) as exc: - raise _translate_run_error(exc, max_turns, "chat") from exc + raise _translate_run_error(exc, max_turns, "chat", client) from exc finally: if not completed and hasattr(streamed, "cancel"): streamed.cancel() # abandoned/failed: stop the agent task @@ -690,15 +712,16 @@ def run_responses(client, input, model: Optional[str] = None, else: raise PageIndexAPIError("input must be a non-empty string or list " "of item dicts.") - block = _doc_block(client, doc_id) + scope = client._local_doc_scope(doc_id) + block = _doc_block(client, doc_id, scoped=scope is not None) conversation = items if block: items = [{"role": "user", "content": block}] + items extra = [instructions] if instructions else [] model_name = model or client.chat_model - managed = _managed_instructions(extra) + managed = _managed_instructions(client, extra) agent = _openai_agent(client, "responses", model_name, managed, - temperature, top_p, doc_ids=doc_id, + temperature, top_p, doc_ids=scope, cache_key=_conversation_cache_key( model_name, managed, doc_id, conversation), reasoning=reasoning, extra_body=extra_body, @@ -752,7 +775,7 @@ def envelope(transcript: list, raw_responses) -> dict: except (MaxTurnsExceeded, AgentsException, openai.OpenAIError) as exc: raise _translate_run_error(exc, max_turns, - "responses") from exc + "responses", client) from exc transcript = result.to_input_list()[len(items):] return envelope(transcript, result.raw_responses) @@ -816,11 +839,11 @@ async def agen(): if (isinstance(exc, MaxTurnsExceeded) or recorded.get("status") not in ("failed", "incomplete")): raise _translate_run_error(exc, max_turns, - "responses") from exc + "responses", client) from exc completed = True except openai.OpenAIError as exc: raise _translate_run_error(exc, max_turns, - "responses") from exc + "responses", client) from exc finally: if not completed and hasattr(streamed, "cancel"): streamed.cancel() # abandoned/failed: stop the agent task @@ -844,7 +867,8 @@ def _require_anthropic() -> None: import anthropic # noqa: F401 except ImportError as exc: raise PageIndexAPIError( - "messages in local mode requires the Anthropic SDK — " + "messages drives your own chat model and requires the " + "Anthropic SDK — " "pip install anthropic (or pip install 'pageindex[anthropic]')." ) from exc try: @@ -852,7 +876,7 @@ def _require_anthropic() -> None: from anthropic.lib.tools import ToolError # noqa: F401 except ImportError as exc: raise PageIndexAPIError( - "messages in local mode requires anthropic >= 0.108.0 (the tool " + "messages requires anthropic >= 0.108.0 (the tool " "runner with ToolError) — pip install -U anthropic." ) from exc @@ -888,13 +912,13 @@ def _anthropic_client(backend=None): return client -def _anthropic_system(extra_system, block: Optional[str]) -> list[dict]: +def _anthropic_system(client, extra_system, block: Optional[str]) -> list[dict]: """System blocks: cache_control marks the stable managed prefix only (the API allows 4 breakpoints total — the varying doc block and caller blocks must not consume the budget); the doc block and caller system content follow as their own blocks.""" blocks = [{"type": "text", - "text": CHAT_HEADER + "\n\n" + AGENT_INSTRUCTIONS, + "text": CHAT_HEADER + "\n\n" + _base_instructions(client), "cache_control": {"type": "ephemeral"}}] if block: blocks.append({"type": "text", "text": block}) @@ -1002,20 +1026,24 @@ def run_messages(client, messages, model: str, or not all(isinstance(message, dict) for message in messages)): raise PageIndexAPIError("messages must be a non-empty string or a " "list of message dicts.") - block = _doc_block(client, doc_id) + scope = client._local_doc_scope(doc_id) + block = _doc_block(client, doc_id, scoped=scope is not None) prepared = [dict(message) for message in messages] passthrough = {key: value for key, value in { "temperature": temperature, "top_p": top_p, "top_k": top_k, "stop_sequences": stop_sequences, "thinking": thinking, "extra_body": extra_body, "extra_headers": extra_headers, }.items() if value is not None} - system_blocks = _anthropic_system(system, block) + system_blocks = _anthropic_system(client, system, block) # Top-level cache_control: the server re-marks the newest block each # turn, so the loop re-reads the growing conversation from cache. # Counts toward the 4-breakpoint limit (live-verified 400 past it). cached: dict[str, Any] = ( {"cache_control": {"type": "ephemeral"}} if _cache_marks(system_blocks, prepared) < 4 else {}) + # Tools before the transport: on a bridge client building them is + # network I/O, and a failure there must not strand the client below. + tools = build_anthropic_tools(client, doc_ids=scope) merged = _merged_backend(client, backend) backend_client = _anthropic_client(merged) # Close only a per-call construction: cached clients stay open for @@ -1028,7 +1056,7 @@ def run_messages(client, messages, model: str, max_tokens=max_tokens, messages=prepared, model=model, - tools=build_anthropic_tools(client, doc_ids=doc_id), + tools=tools, system=system_blocks, stream=stream, # Bounded like the OpenAI surfaces (their framework default is 10). @@ -1044,8 +1072,7 @@ def events() -> Iterator[Any]: for event in turn_stream: yield event except anthropic.AnthropicError as exc: - raise PageIndexAPIError( - f"The model backend failed: {exc}") from exc + raise _model_backend_error(exc, "messages", client) from exc except TypeError as exc: # the SDK's request-time credential-resolution failure if "authentication" not in str(exc).lower(): @@ -1063,8 +1090,7 @@ def events() -> Iterator[Any]: try: turns = [turn for turn in runner] except anthropic.AnthropicError as exc: - raise PageIndexAPIError( - f"The model backend failed: {exc}") from exc + raise _model_backend_error(exc, "messages", client) from exc except TypeError as exc: # the SDK's request-time credential-resolution failure if "authentication" not in str(exc).lower(): diff --git a/pageindex/py.typed b/pageindex/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/pageindex/types.py b/pageindex/types.py new file mode 100644 index 000000000..ee0c44902 --- /dev/null +++ b/pageindex/types.py @@ -0,0 +1,48 @@ +"""Config shapes for the constructor's ``index=`` / ``chat=`` slots. + +The slots are the grouped spelling of the flat constructor arguments — +the same arguments with the side prefix factored out of the names +(``index={"model": ...}`` is ``index_model=``), one spelling per side. +A dict declares its +side by its keys (cloud takes a key, local takes models); an optional +``"mode"`` field states the side explicitly and must agree with the +other keys. The ``"pageindex-cloud"`` string is the label spelling for +"this side is managed" — a synonym of ``"cloud"``. +""" +from __future__ import annotations + +from typing import Literal, TypedDict, Union + +PAGEINDEX_CLOUD = "pageindex-cloud" + + +class CloudIndexConfig(TypedDict, total=False): + """Documents hosted on PageIndex cloud. ``api_key`` may be omitted + when ``mode: "cloud"`` stays — it then comes from the + PAGEINDEX_API_KEY environment variable.""" + + mode: Literal["cloud"] + api_key: str + + +class LocalIndexConfig(TypedDict, total=False): + """Documents indexed and stored locally.""" + + mode: Literal["local"] + model: str + summary_model: str + backend: dict + storage_path: str + + +class ChatConfig(TypedDict, total=False): + """The chat side: ``mode: "local"`` (or any model/backend key) is + your own model — the agent runs in your process on your keys; + ``{"mode": "cloud"}`` alone is the managed chat.""" + + mode: Literal["cloud", "local"] + model: str + backend: dict + + +IndexConfig = Union[CloudIndexConfig, LocalIndexConfig] diff --git a/pageindex/utils.py b/pageindex/utils.py index 451af6a96..1ad54b1b9 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -10,8 +10,8 @@ import copy import asyncio from io import BytesIO -from dotenv import load_dotenv -load_dotenv() +from dotenv import find_dotenv, load_dotenv +load_dotenv(find_dotenv(usecwd=True) or None) import logging import yaml from pathlib import Path diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py index c1c192d80..e34c2caae 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -2647,3 +2647,23 @@ def test_agent_tools_doc_id_refused_on_cloud(): cloud = PageIndexCloudClient(api_key="pi-test-key") with pytest.raises(PageIndexAPIError, match="local tools only"): cloud.agent_tools(doc_id="pi-a") + + +def test_cloud_tool_list_empty_raises(monkeypatch): + """An empty tools/list must raise like empty instructions does: a + zero-tool agent answers from the model's own knowledge instead of + the documents, with nothing to signal it.""" + pytest.importorskip("agents") + import pageindex.mcp_bridge as mcp_bridge + + class _ToollessBridge: + def __init__(self, url, headers): + pass + + def list_tools(self): + return [] + + monkeypatch.setattr(mcp_bridge, "McpBridge", _ToollessBridge) + cloud = PageIndexCloudClient(api_key="pi-test-key") + with pytest.raises(PageIndexAPIError, match="no tools"): + cloud.as_openai_tools() diff --git a/tests/test_client.py b/tests/test_client.py index de6c76439..b965a82b6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,9 +2,13 @@ import asyncio import importlib import json +import os import re import shutil +import subprocess +import sys import types +from pathlib import Path import pytest @@ -109,14 +113,22 @@ def test_model_resolution_covers_every_generation(tmp_path): assert client.retrieve_model == client.chat_model, kwargs -def test_explicit_mode_clients(tmp_path): +def test_explicit_mode_clients(tmp_path, monkeypatch): from pageindex import PageIndexCloudClient, PageIndexLocalClient + monkeypatch.delenv("PAGEINDEX_API_KEY", raising=False) for bad_key in (None, ""): with pytest.raises(PageIndexAPIError, match="requires a PageIndex API key"): PageIndexCloudClient(bad_key) cloud = PageIndexCloudClient("k") assert cloud.api_key == "k" and isinstance(cloud, PageIndexClient) + # The class name says cloud, so the env key may fill the value — + # the shortest env-key cloud construction. Explicit "" still raises. + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexCloudClient().api_key == "pi-env" + assert PageIndexCloudClient("k").api_key == "k" + with pytest.raises(PageIndexAPIError, match="requires a PageIndex API key"): + PageIndexCloudClient("") local = PageIndexLocalClient(model="m", storage_path=str(tmp_path / "s")) assert local.model == "m" and isinstance(local, PageIndexClient) @@ -124,6 +136,377 @@ def test_explicit_mode_clients(tmp_path): PageIndexLocalClient("k") +# ── constructor matrix: two sides, one spelling each ── + + +def test_bridge_cloud_docs_own_model(): + """chat-side arguments on a cloud client select own-model chat.""" + from pageindex.utils import DEFAULT_CHAT_MODEL + client = PageIndexClient(api_key="pi-k", chat_model="openai/m") + assert client.api_key == "pi-k" and client._local_chat + assert client.chat_model == "openai/m" and client.chat_backend is None + assert not PageIndexClient(api_key="pi-k")._local_chat + # Only the backend given: the chat model falls back to the default. + partial = PageIndexClient(api_key="pi-k", chat_backend={"api_key": "x"}) + assert partial._local_chat and partial.chat_model == DEFAULT_CHAT_MODEL + # The pinned classes carry the flag too. + from pageindex import PageIndexCloudClient, PageIndexLocalClient + assert not PageIndexCloudClient("k")._local_chat + assert PageIndexLocalClient()._local_chat + # The pinned classes pin only the index side: the chat side stays + # free, same vocabulary as PageIndexClient. + pinned = PageIndexCloudClient("k", chat_model="openai/m") + assert pinned._local_chat and pinned.chat_model == "openai/m" + assert PageIndexCloudClient("k", chat={"model": "m"}).chat_model == "m" + assert PageIndexLocalClient(chat={"model": "m"}).chat_model == "m" + assert not PageIndexCloudClient("k", chat="pageindex-cloud")._local_chat + + +def test_local_pinned_class_takes_index_slot(tmp_path, monkeypatch): + """The local pinned class takes the grouped spelling of the index + vocabulary it already takes flat; a cloud index= is refused in the + class's name, never a cloud client.""" + from pageindex import PageIndexLocalClient + client = PageIndexLocalClient( + index={"model": "i", "storage_path": str(tmp_path / "a")}) + assert client.index_model == "i" + assert client.storage_path == str(tmp_path / "a") + assert PageIndexLocalClient(index="i").index_model == "i" + # The mode= disagreement fires before any environment read. + monkeypatch.setattr("pageindex.client._env_cloud_key", lambda *a: ( + pytest.fail("environment read before the mode cross-check"))) + for cloud in ("cloud", "pageindex-cloud", {"api_key": "k"}, + {"mode": "cloud"}): + with pytest.raises(PageIndexAPIError, + match="PageIndexLocalClient pins local documents"): + PageIndexLocalClient(index=cloud) + + +def test_cloud_pinned_class_takes_index_slot(monkeypatch): + """The cloud pinned class takes index= for the key; a local index= is + refused in the class's name, never a local client.""" + from pageindex import PageIndexCloudClient + assert PageIndexCloudClient(index={"api_key": "k"}).api_key == "k" + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexCloudClient(index="cloud").api_key == "pi-env" + for local in ("local", "i-model", {"model": "m"}, {"storage_path": "/x"}): + with pytest.raises(PageIndexAPIError, + match="PageIndexCloudClient pins cloud documents"): + PageIndexCloudClient(index=local) + with pytest.raises(PageIndexAPIError, match="two spellings"): + PageIndexCloudClient("k", index={"api_key": "k"}) + monkeypatch.delenv("PAGEINDEX_API_KEY") + with pytest.raises(PageIndexAPIError, match='index="cloud" reads'): + PageIndexCloudClient(index="cloud") + + +def test_pinned_class_errors_name_a_reachable_exit(): + """The pinned classes have no mode= (and Local no api_key=): their + refusals name the class and an exit that class can take, never a + remedy that only PageIndexClient accepts. An explicit mode="local" + is named the same way — the exit has to drop it.""" + from pageindex import PageIndexCloudClient, PageIndexLocalClient + with pytest.raises(PageIndexAPIError) as err: + PageIndexLocalClient(chat="cloud") + message = str(err.value) + assert "PageIndexLocalClient pins local documents" in message + assert 'index="cloud"' not in message and "mode=" not in message + # The exits it names construct. + assert not PageIndexCloudClient("k", chat="cloud")._local_chat + assert not PageIndexClient(api_key="k", chat="cloud")._local_chat + assert PageIndexLocalClient(chat="m")._local_chat + with pytest.raises(PageIndexAPIError, match='and drop mode="local"'): + PageIndexClient(mode="local", chat="cloud") + with pytest.raises(PageIndexAPIError) as err: + PageIndexClient(chat="cloud") + assert "mode=" not in str(err.value) + + +def test_mode_words_normalize_like_the_label(monkeypatch): + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexClient(mode=" Cloud ").api_key == "pi-env" + assert not hasattr(PageIndexClient(index={"mode": "LOCAL"}), "api_key") + assert not PageIndexClient(api_key="k", chat={"mode": "Cloud"})._local_chat + + +def test_cloud_index_args_still_rejected(): + with pytest.raises(PageIndexAPIError, match="index_model"): + PageIndexClient(api_key="k", index_model="m") + # ``model`` claims both sides, so the index side rejects it — the + # error points at the chat-side spelling that stays available. + with pytest.raises(PageIndexAPIError, match="stay yours"): + PageIndexClient(api_key="k", model="m") + + +def test_index_slot_spellings(tmp_path, monkeypatch): + client = PageIndexClient(index="i-model") + assert client.index_model == "i-model" and client._local_chat + assert not hasattr(client, "api_key") + + full = PageIndexClient(index={"model": "i", "summary_model": "s", + "backend": {"api_key": "b"}, + "storage_path": str(tmp_path / "s")}) + assert (full.index_model, full.summary_model) == ("i", "s") + assert full.storage_path == str(tmp_path / "s") + + inline = PageIndexClient(index={"api_key": "pi-k"}) + assert inline.api_key == "pi-k" and not inline._local_chat + + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexClient(index="pageindex-cloud").api_key == "pi-env" + # An inline key wins over the environment (env is never consulted). + assert PageIndexClient(index={"api_key": "pi-x"}).api_key == "pi-x" + monkeypatch.delenv("PAGEINDEX_API_KEY") + with pytest.raises(PageIndexAPIError, match="PAGEINDEX_API_KEY"): + PageIndexClient(index="pageindex-cloud") + + +def test_chat_slot_spellings(): + assert PageIndexClient(chat="openai/m").chat_model == "openai/m" + full = PageIndexClient(chat={"model": "m", "backend": {"base_url": "u"}}) + assert (full.chat_model, full.chat_backend) == ("m", {"base_url": "u"}) + + bridge = PageIndexClient(index={"api_key": "k"}, chat="openai/m") + assert bridge._local_chat and bridge.api_key == "k" + managed = PageIndexClient(index={"api_key": "k"}, chat="pageindex-cloud") + assert not managed._local_chat + # The impossible cell: local documents cannot feed the managed chat. + with pytest.raises(PageIndexAPIError, match="cannot read the local store"): + PageIndexClient(chat="pageindex-cloud") + + +def test_slot_flat_equivalence(tmp_path): + """The slots are the grouped spelling of the flat arguments — same + names, same resolution.""" + flat = PageIndexClient(index_model="i", chat_model="c", + chat_backend={"k": 1}, + storage_path=str(tmp_path / "a")) + slot = PageIndexClient( + index={"model": "i", "storage_path": str(tmp_path / "a")}, + chat={"model": "c", "backend": {"k": 1}}) + for attr in ("model", "index_model", "summary_model", "chat_model", + "chat_backend", "storage_path", "_local_chat"): + assert getattr(flat, attr) == getattr(slot, attr), attr + + +def test_same_side_double_spelling_rejected(): + for kwargs in ({"api_key": "k", "index": {"api_key": "k"}}, + {"index": "m", "index_model": "m"}, + {"index": "m", "storage_path": "/x"}, + {"chat": "m", "chat_model": "m"}): + with pytest.raises(PageIndexAPIError, match="two spellings"): + PageIndexClient(**kwargs) + # Mixing tiers across sides is fine — the rule is per side. + assert PageIndexClient(api_key="k", chat={"model": "m"})._local_chat + + +def test_slot_validation_errors(monkeypatch): + for bad, msg in [({}, "empty dict"), + ({"api_key": "k", "model": "m"}, "mixes cloud and local"), + ({"nope": 1}, "Unknown index keys"), + ({"api_key": ""}, "non-empty string")]: + with pytest.raises(PageIndexAPIError, match=msg): + PageIndexClient(index=bad) + for bad, msg in [({}, "empty dict"), ({"nope": 1}, "Unknown chat keys")]: + with pytest.raises(PageIndexAPIError, match=msg): + PageIndexClient(chat=bad) + with pytest.raises(PageIndexAPIError, match="string or a dict"): + PageIndexClient(index=5) + with pytest.raises(PageIndexAPIError, match="string or a dict"): + PageIndexClient(chat=5) + with pytest.raises(PageIndexAPIError, match="empty string"): + PageIndexClient(index=" ") + with pytest.raises(PageIndexAPIError, match="empty string"): + PageIndexClient(chat=" ") + # Case/whitespace variants of the label never fall through to a + # silent model name. + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexClient(index=" Pageindex-Cloud ").api_key == "pi-env" + assert not PageIndexClient(index={"api_key": "k"}, + chat="PAGEINDEX-CLOUD")._local_chat + # Bare mode words are the label's short form; near-synonyms point at + # the real word instead of parsing as model names. + assert PageIndexClient(index=" Cloud ").api_key == "pi-env" + assert not hasattr(PageIndexClient(index="local"), "api_key") + assert not PageIndexClient(api_key="k", chat="cloud")._local_chat + assert PageIndexClient(api_key="k", chat="local")._local_chat + for word in ("Hosted", " managed "): + with pytest.raises(PageIndexAPIError, match="not a mode word"): + PageIndexClient(index=word) + with pytest.raises(PageIndexAPIError, match="not a mode word"): + PageIndexClient(chat=word) + + +def test_bare_client_ignores_env_key(monkeypatch): + """PAGEINDEX_API_KEY never moves the documents on its own — only code + that explicitly says cloud reads it.""" + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + client = PageIndexClient() + assert not hasattr(client, "api_key") and client._local_chat + + +def test_env_key_reads_load_dotenv(): + """The SDK's .env support is pageindex.utils' import-time + load_dotenv(); every spelling that reads PAGEINDEX_API_KEY must + trigger it, or a key in .env is visible only when something else + imported utils first. The sentinel finder stands in for the .env + file: importing pageindex.utils makes the key appear.""" + probe = "\n".join([ + "import importlib.abc, os, sys", + "class Sentinel(importlib.abc.MetaPathFinder):", + " def find_spec(self, name, path=None, target=None):", + " if name == 'pageindex.utils':", + " os.environ.setdefault('PAGEINDEX_API_KEY', 'pi-dotenv')", + " return None", + "sys.meta_path.insert(0, Sentinel())", + "import pageindex", + "from pageindex import PageIndexClient, PageIndexCloudClient", + "for build in (lambda: PageIndexClient(index='pageindex-cloud'),", + " lambda: PageIndexClient(mode='cloud'),", + " lambda: PageIndexClient(index={'mode': 'cloud'}),", + " lambda: PageIndexCloudClient()):", + " sys.modules.pop('pageindex.utils', None)", + " pageindex.__dict__.pop('utils', None)", + " os.environ.pop('PAGEINDEX_API_KEY', None)", + " assert build().api_key == 'pi-dotenv', build", + "print('ok')", + ]) + out = subprocess.run([sys.executable, "-c", probe], + capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() == "ok" + + +def test_env_key_found_from_cwd(tmp_path): + """A pip-installed SDK lives in site-packages; the user's .env lives + at their project root. A bare load_dotenv() searches upward from + utils.py, so it found a checkout's .env and never an installed + user's. A script file, not -c: dotenv treats a file-less __main__ as + interactive and searches from the cwd regardless.""" + (tmp_path / ".env").write_text("PAGEINDEX_API_KEY=pi-dotenv-cwd\n") + (tmp_path / "app.py").write_text( + "from pageindex import PageIndexCloudClient\n" + "print('ok' if PageIndexCloudClient().api_key == 'pi-dotenv-cwd'\n" + " else 'other')\n") + env = {**os.environ, "PYTHONPATH": str(Path(__file__).parent.parent)} + env.pop("PAGEINDEX_API_KEY", None) + out = subprocess.run([sys.executable, "app.py"], cwd=tmp_path, env=env, + capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() == "ok" + + +def test_empty_values_refused_never_silent(): + """An empty value configures nothing — pre-fix, an empty chat-side + value on a cloud client silently selected own-model chat on the + default model.""" + for kwargs in ({"chat_model": ""}, {"chat_model": " "}, + {"retrieve_model": ""}, + {"chat_backend": {}}, {"model": ""}, + {"index_model": ""}, {"index_backend": {}}, + {"storage_path": ""}): + with pytest.raises(PageIndexAPIError, match="configures nothing"): + PageIndexClient(**kwargs) + if next(iter(kwargs)) in ("chat_model", "retrieve_model", + "chat_backend"): + with pytest.raises(PageIndexAPIError, match="configures nothing"): + PageIndexClient(api_key="pi-k", **kwargs) + with pytest.raises(PageIndexAPIError, match="configures nothing"): + PageIndexClient(api_key="pi-k", chat={"model": ""}) + with pytest.raises(PageIndexAPIError, match="configures nothing"): + PageIndexClient(api_key="pi-k", chat={"model": " "}) + with pytest.raises(PageIndexAPIError, match="configures nothing"): + PageIndexClient(chat={"backend": {}}) + # None-valued slot keys mean "absent", exactly like the flat args — + # a slot left with nothing real is the empty-dict error. + with pytest.raises(PageIndexAPIError, match="empty dict"): + PageIndexClient(api_key="pi-k", chat={"model": None}) + with pytest.raises(PageIndexAPIError, match="empty dict"): + PageIndexClient(index={"model": None}) + + +def test_model_umbrella_names_split_for_slots(tmp_path): + """model= sets both roles, so no slot can absorb it — the error + teaches a rewrite that works: the model inside the slot, the flat + role name for the other side.""" + for kwargs in ({"model": "m", "chat": {"backend": {"base_url": "u"}}}, + {"model": "m", "index": {"storage_path": "/x"}}, + {"model": "m", "chat": "c"}): + with pytest.raises(PageIndexAPIError, + match="name the model inside the slot"): + PageIndexClient(**kwargs) + # Following the remedy constructs a working client. + client = PageIndexClient( + index={"model": "m", "storage_path": str(tmp_path)}, chat_model="m") + assert client.index_model == client.chat_model == "m" + client = PageIndexClient( + index_model="m", chat={"model": "m", "backend": {"base_url": "u"}}) + assert client.index_model == client.chat_model == "m" + + +def test_post_construction_chat_model_switches_whole_client(): + """chat_model is documented as assignable; the mode must follow the + attribute, never a stale construction-time snapshot.""" + client = PageIndexClient(api_key="pi-k") + assert not client._local_chat + client.chat_model = "openai/m" + assert client._local_chat + legacy = PageIndexClient(api_key="pi-k") + legacy.retrieve_model = "m2" # the 0.2.9 write path + assert legacy._local_chat and legacy.chat_model == "m2" + + +def test_keyless_cloud_hint_matches_the_spelling(monkeypatch): + """Following the error's own remedy must construct a working client + — the index= spellings cannot combine with flat api_key=.""" + monkeypatch.delenv("PAGEINDEX_API_KEY", raising=False) + with pytest.raises(PageIndexAPIError) as err: + PageIndexClient(index="pageindex-cloud") + assert 'index={"api_key": ...}' in str(err.value) + with pytest.raises(PageIndexAPIError) as err: + PageIndexClient(index={"mode": "cloud"}) + assert 'index={"api_key": ...}' in str(err.value) + with pytest.raises(PageIndexAPIError) as err: + PageIndexClient(mode="cloud") + assert "(api_key=...)" in str(err.value) + assert PageIndexClient(mode="cloud", api_key="pi-k").api_key == "pi-k" + + +def test_argument_type_errors_are_pageindex_errors(): + for kwargs, msg in (({"index": {"storage_path": 5}}, + r'index\["storage_path"\] must be a str'), + ({"chat": {"backend": "x"}}, + r'chat\["backend"\] must be a dict'), + ({"chat_backend": "x"}, "chat_backend must be a dict"), + ({"chat_model": 5}, "chat_model must be a str"), + ({"index_backend": ["x"]}, + "index_backend must be a dict")): + with pytest.raises(PageIndexAPIError, match=msg): + PageIndexClient(**kwargs) + + +def test_strings_are_stripped_in_every_spelling(): + for client in (PageIndexClient(index=" i-model ", chat=" openai/m "), + PageIndexClient(index={"model": " i-model "}, + chat={"model": " openai/m "}), + PageIndexClient(index_model=" i-model ", + chat_model=" openai/m ")): + assert client.index_model == "i-model" + assert client.chat_model == "openai/m" + + +def test_managed_chat_client_reads_none_not_attribute_error(): + """The docstring advertises client.chat_model on every client; a + managed-chat client answers None (the endpoint picks its own model).""" + client = PageIndexClient(api_key="pi-k") + assert client.chat_model is None + assert client.retrieve_model is None + assert client.chat_backend is None + assert not client._local_chat + client.chat_model = "openai/m" # and the switch still flips + assert client._local_chat + + # ── local: indexing and reading ── def test_submit_and_get_tree(local_client, indexed_doc, tmp_path, monkeypatch): @@ -1207,11 +1590,14 @@ def test_custom_provider_map_passes_provider_precheck(monkeypatch): assert _litellm_model("my-llm/model-a") == "my-llm/model-a" -def test_backend_args_are_local_only(): - with pytest.raises(PageIndexAPIError, match="chat_backend"): - PageIndexClient(api_key="pi-k", chat_backend={"api_key": "x"}) +def test_index_backend_is_local_only_chat_backend_selects_own_model(): + """index_backend has nothing to configure on cloud (the managed + pipeline indexes); chat_backend is a chat-side argument, so on a + cloud client it selects own-model chat like chat_model does.""" with pytest.raises(PageIndexAPIError, match="index_backend"): PageIndexClient(api_key="pi-k", index_backend={"api_key": "x"}) + client = PageIndexClient(api_key="pi-k", chat_backend={"api_key": "x"}) + assert client._local_chat and client.chat_backend == {"api_key": "x"} def test_chat_wraps_answerless_cloud_reply(monkeypatch): @@ -1444,3 +1830,96 @@ async def slow_empty(model, prompt): do_expand=True)) assert inflight["peak"] > 1 assert inflight["peak"] <= 32 + + +def test_mode_declaration_top_level(monkeypatch): + """mode= states where documents live; always optional, always + checked, and mode="cloud" alone reads the env key.""" + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexClient(mode="cloud").api_key == "pi-env" + assert not PageIndexClient(mode="cloud")._local_chat + bridge = PageIndexClient(mode="cloud", chat_model="openai/m") + assert bridge._local_chat and bridge.api_key == "pi-env" + agreed = PageIndexClient(api_key="pi-x", mode="cloud") + assert agreed.api_key == "pi-x" + local = PageIndexClient(mode="local") + assert local._local_chat and not hasattr(local, "api_key") + + monkeypatch.delenv("PAGEINDEX_API_KEY") + with pytest.raises(PageIndexAPIError, match="PAGEINDEX_API_KEY"): + PageIndexClient(mode="cloud") + with pytest.raises(PageIndexAPIError, match="conflicts with api_key"): + PageIndexClient(api_key="k", mode="local") + with pytest.raises(PageIndexAPIError, match='"cloud" or "local"'): + PageIndexClient(mode="banana") + + # mode= beside index= is the same cross-check: agreement passes, + # disagreement errors, and the vocabulary check still comes first. + assert PageIndexClient(mode="local", index="m").index_model == "m" + assert PageIndexClient(mode="cloud", + index={"api_key": "pi-x"}).api_key == "pi-x" + with pytest.raises(PageIndexAPIError, match="disagrees with index="): + PageIndexClient(mode="cloud", index="m") + with pytest.raises(PageIndexAPIError, match="disagrees with index="): + PageIndexClient(mode="local", index={"api_key": "k"}) + with pytest.raises(PageIndexAPIError, match='"cloud" or "local"'): + PageIndexClient(mode="banana", index="m") + + +def test_mode_declaration_in_index_dict(monkeypatch): + monkeypatch.setenv("PAGEINDEX_API_KEY", "pi-env") + assert PageIndexClient(index={"mode": "cloud"}).api_key == "pi-env" + assert PageIndexClient( + index={"mode": "cloud", "api_key": "pi-x"}).api_key == "pi-x" + declared = PageIndexClient(index={"mode": "local", "model": "i"}) + assert declared.index_model == "i" + assert not hasattr(PageIndexClient(index={"mode": "local"}), "api_key") + + with pytest.raises(PageIndexAPIError, match='mode "cloud" but carries'): + PageIndexClient(index={"mode": "cloud", "model": "i"}) + with pytest.raises(PageIndexAPIError, match='mode "local" but carries'): + PageIndexClient(index={"mode": "local", "api_key": "k"}) + with pytest.raises(PageIndexAPIError, match='"cloud" or "local"'): + PageIndexClient(index={"mode": "hosted"}) + # The key is "mode"; the pre-release "type" is just an unknown key. + with pytest.raises(PageIndexAPIError, match=r"Unknown index keys \(type\)"): + PageIndexClient(index={"type": "cloud"}) + + +def test_mode_declaration_in_chat_dict(): + managed = PageIndexClient(api_key="pi-k", chat={"mode": "cloud"}) + assert not managed._local_chat + # {"mode": "local"} alone declares own-model chat — default model. + from pageindex.utils import DEFAULT_CHAT_MODEL + own = PageIndexClient(api_key="pi-k", chat={"mode": "local"}) + assert own._local_chat and own.chat_model == DEFAULT_CHAT_MODEL + declared = PageIndexClient(chat={"mode": "local", "model": "m"}) + assert declared.chat_model == "m" + + with pytest.raises(PageIndexAPIError, match='mode "cloud" but carries'): + PageIndexClient(api_key="pi-k", chat={"mode": "cloud", "model": "m"}) + with pytest.raises(PageIndexAPIError, match="cannot read the local store"): + PageIndexClient(chat={"mode": "cloud"}) + with pytest.raises(PageIndexAPIError, match=r"Unknown chat keys \(type\)"): + PageIndexClient(api_key="pi-k", chat={"type": "cloud"}) + + +def test_blank_chat_model_assignment_stays_managed(): + """The constructor refuses chat_model="" as configuring nothing, so + the assignment path must agree — cfg.get("chat_model", "") otherwise + opens the bridge and hands LiteLLM a nameless model.""" + client = PageIndexClient(api_key="pi-k") + for blank in ("", " "): + client.chat_model = blank + assert not client._local_chat, repr(blank) + client.retrieve_model = "" + assert not client._local_chat + + +def test_blank_chat_model_carries_no_model_into_agent_config(): + """Same rule at the config door: a blank chat_model must not become + a model literally named " " in the returned config.""" + pytest.importorskip("agents") + client = PageIndexClient() + client.chat_model = " " + assert "model" not in client.openai_agent_config() diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index b9c29b139..509291e8a 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -14,8 +14,8 @@ anthropic_httpx = httpx import pageindex.local_chat as local_chat -from pageindex import (PageIndexAPIError, PageIndexCloudClient, - PageIndexLocalClient) +from pageindex import (PageIndexAPIError, PageIndexClient, + PageIndexCloudClient, PageIndexLocalClient) from pageindex.local_chat import CHAT_HEADER from pageindex.local_store import DocStore @@ -242,7 +242,7 @@ def test_chat_completions_accepts_query_string(client, store_path, fake_model): @needs_agents def test_chat_completions_validation(client, store_path, fake_model): fake_model([[_msg_item("ok")]]) - with pytest.raises(PageIndexAPIError, match="cloud-only"): + with pytest.raises(PageIndexAPIError, match="managed chat endpoint"): client.chat_completions([{"role": "user", "content": "x"}], enable_citations=True) with pytest.raises(PageIndexAPIError, match="responses\\(\\) or messages"): @@ -284,32 +284,30 @@ def test_chat_completions_missing_framework(client, monkeypatch): def test_cloud_guards(): cloud = PageIndexCloudClient(api_key="pi-test-key") - with pytest.raises(PageIndexAPIError, match="local-mode parameters"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], model="m") - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], reasoning_effort="low") - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], extra_body={"service_tier": "auto"}) - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], top_p=0.9) - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], max_tokens=256) - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat("x", reasoning_effort="low") - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], backend={"api_key": "k"}) - with pytest.raises(PageIndexAPIError, match="local-mode"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.chat_completions([{"role": "user", "content": "x"}], extra_headers={"x-beta": "1"}) - with pytest.raises(PageIndexAPIError, match="not available on PageIndex " - "cloud yet"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.responses("x") - with pytest.raises(PageIndexAPIError, match="not available on PageIndex " - "cloud yet"): + with pytest.raises(PageIndexAPIError, match="own chat model"): cloud.messages([{"role": "user", "content": "x"}], model="m", max_tokens=10) @@ -877,9 +875,17 @@ def test_max_turns_rejects_non_positive(client, store_path, fake_model): def test_enable_citations_rejected_before_framework_check(client, monkeypatch): monkeypatch.setitem(sys.modules, "agents", None) - with pytest.raises(PageIndexAPIError, match="cloud-only"): + with pytest.raises(PageIndexAPIError, match="managed chat endpoint"): client.chat_completions([{"role": "user", "content": "x"}], enable_citations=True) + # A cloud own-model client is told the real gate — managed vs own + # chat — never "cloud-only": it is on the cloud. + cloud = PageIndexClient(api_key="pi-k", chat_model="m") + with pytest.raises(PageIndexAPIError) as err: + cloud.chat_completions([{"role": "user", "content": "x"}], + enable_citations=True) + assert "cloud-only" not in str(err.value) + assert "drop the chat model" in str(err.value) @needs_agents @@ -2182,3 +2188,249 @@ def test_default_max_tokens_respects_output_ceilings(): {"type": "enabled", "budget_tokens": 10000}) == 18192 assert lift("claude-sonnet-4-5", {"type": "enabled", "budget_tokens": True}) == 8192 + + +# ── own-model chat over cloud documents (the bridge) ── + + +class FakeBridge: + """Stands in for the cloud MCP bridge: one read tool, live + instructions, recorded calls.""" + + def __init__(self): + self.calls = [] + + def instructions(self): + return "CLOUD LIVE INSTRUCTIONS" + + def list_tools(self): + return [{"name": "get_document", + "description": "Cloud get_document", + "inputSchema": {"type": "object", "properties": { + "doc_name": {"type": "string"}}}}] + + def call_tool(self, name, arguments): + self.calls.append((name, arguments)) + return json.dumps({"status": "success", + "data": {"doc": "cloud-doc"}}), False + + +@pytest.fixture +def bridge_client(monkeypatch): + import pageindex.agent_tools as agent_tools + from pageindex import PageIndexClient + bridge = FakeBridge() + monkeypatch.setattr(agent_tools, "_cloud_bridge", + lambda client, gated=True: bridge) + client = PageIndexClient(api_key="pi-k", chat_model="fake-model") + return client, bridge + + +@needs_agents +def test_bridge_chat_runs_engine_over_cloud_tools(bridge_client, fake_model): + """A cloud client with its own chat model runs the in-process agent: + tools come from the live cloud MCP set, instructions from the MCP + server — not the local subset.""" + client, bridge = bridge_client + fake = fake_model([ + [_call_item("get_document", {"doc_name": "r.pdf"})], + [_msg_item("The answer")], + ]) + result = client.chat_completions("What?") + assert result["choices"][0]["message"]["content"] == "The answer" + assert bridge.calls == [("get_document", {"doc_name": "r.pdf"})] + assert fake.instructions[0].startswith(CHAT_HEADER) + assert "CLOUD LIVE INSTRUCTIONS" in fake.instructions[0] + assert "READING WORKFLOW" not in fake.instructions[0] + # The tool result made it back into turn 2. + assert "cloud-doc" in json.dumps(fake.inputs[1]) + + +@needs_agents +def test_bridge_doc_id_targets_at_prompt_level(bridge_client, fake_model, + monkeypatch): + """On cloud tools there is no local allowlist: doc_id becomes the + prompt-level targeting block only. (Had the tool layer received the + doc_ids, _require_local_scope would raise on a cloud client — this + call succeeding is the proof it did not.)""" + client, _ = bridge_client + monkeypatch.setattr(client, "get_document", + lambda doc_id: {"name": "r.pdf", "description": "d", + "status": "completed", + "metadata": None}) + monkeypatch.setattr( + client, "list_documents", + lambda **kw: {"documents": [{"id": "pi-a", "name": "r.pdf"}], + "total": 1}) + fake = fake_model([[_msg_item("ok")]]) + client.chat_completions("q", doc_id="pi-a") + first = fake.inputs[0][0] + assert "specified document" in first["content"] + assert "r.pdf" in first["content"] + + +def test_bridge_gate_and_citations(monkeypatch): + from pageindex import PageIndexClient + client = PageIndexClient(api_key="pi-k", chat_model="m") + with pytest.raises(PageIndexAPIError, match="drop the chat model"): + client.chat_completions("x", enable_citations=True) + + called = {} + + def fake_responses(client_arg, *args, **kwargs): + called["responses"] = client_arg + return {} + + def fake_messages(client_arg, *args, **kwargs): + called["messages"] = client_arg + return {} + + monkeypatch.setattr(local_chat, "run_responses", fake_responses) + monkeypatch.setattr(local_chat, "run_messages", fake_messages) + client.responses("q") + client.messages("q", model="mm") + assert called["responses"] is client and called["messages"] is client + + +def test_bridge_auth_failure_error_teaches_architecture(): + """The misreading ("the cloud runs my model") surfaces as a missing + provider key — that error is where the architecture gets spelled out, + and only there: local clients and non-auth failures stay untouched.""" + from pageindex import PageIndexClient + bridge = PageIndexClient(api_key="pi-k", chat_model="m") + local = PageIndexLocalClient() + exc = Exception("The api_key client option must be set") + assert "your own provider credentials" in str( + local_chat._model_backend_error(exc, "chat", bridge)) + assert "provider credentials" not in str( + local_chat._model_backend_error(exc, "chat", local)) + assert "provider credentials" not in str( + local_chat._model_backend_error(Exception("boom"), "chat", bridge)) + # 401-shaped failures carry no "api key" text on some providers + # (Anthropic says x-api-key): the status code is the signal. + denied = Exception("invalid x-api-key") + denied.status_code = 401 + assert "your own provider credentials" in str( + local_chat._model_backend_error(denied, "messages", bridge)) + + +def test_bridge_auth_note_managed_exit_is_chat_lane_only(): + """The managed-chat exit ("drop the chat model") is real only for + chat_completions(); responses() and messages() refuse a client + without an own model, so on those lanes the note keeps the + credentials advice and drops the exit that would send the caller in + a circle.""" + from pageindex import PageIndexClient + bridge = PageIndexClient(api_key="pi-k", chat_model="m") + denied = Exception("invalid x-api-key") + denied.status_code = 401 + chat = str(local_chat._model_backend_error(denied, "chat", bridge)) + assert "drop the chat model" in chat + for lane in ("responses", "messages"): + text = str(local_chat._model_backend_error(denied, lane, bridge)) + assert "your own provider credentials" in text + assert "drop the chat model" not in text + + +@needs_anthropic +def test_messages_auth_failure_teaches_architecture(bridge_client, + monkeypatch): + """The auth note must reach the messages door too — both its paths + wrap provider failures through _model_backend_error.""" + client, _ = bridge_client + + def handler(request): + return anthropic_httpx.Response( + 401, json={"type": "error", + "error": {"type": "authentication_error", + "message": "invalid x-api-key"}}) + + def fresh_fake(backend=None): + # per call: run_messages closes a per-call transport it owns + return anthropic.Anthropic( + api_key="test", + http_client=anthropic_httpx.Client( + transport=anthropic_httpx.MockTransport(handler))) + + monkeypatch.setattr(local_chat, "_anthropic_client", fresh_fake) + with pytest.raises(PageIndexAPIError, match="provider credentials"): + client.messages("q", model="claude-test", max_tokens=100) + with pytest.raises(PageIndexAPIError, match="provider credentials"): + list(client.messages("q", model="claude-test", max_tokens=100, + stream=True)) + + +@needs_anthropic +def test_messages_no_backend_leak_when_tool_build_fails(bridge_client, + monkeypatch): + """build_anthropic_tools is network I/O on a bridge client — a + failure there must not strand an opened per-call transport.""" + client, _ = bridge_client + made = [] + + class FakeAnthropic: + def __init__(self): + self.closed = False + self.beta = types.SimpleNamespace(messages=types.SimpleNamespace( + tool_runner=lambda **kw: iter(()))) + + def close(self): + self.closed = True + + monkeypatch.setattr(local_chat, "_anthropic_client", + lambda backend=None: made.append(FakeAnthropic()) + or made[-1]) + + def boom(client, doc_ids=None): + raise PageIndexAPIError("Could not reach the PageIndex MCP server") + + monkeypatch.setattr( + "pageindex.integrations.anthropic_sdk.build_anthropic_tools", boom) + with pytest.raises(PageIndexAPIError, match="MCP server"): + client.messages("q", model="claude-test", max_tokens=100) + assert all(fake.closed for fake in made) + + +@needs_agents +def test_bridge_responses_lane_runs_cloud_tools(bridge_client, fake_model): + """The Responses door on a bridge client: same engine, cloud tools, + live instructions.""" + client, bridge = bridge_client + fake = fake_model([ + [_call_item("get_document", {"doc_name": "r.pdf"})], + [_msg_item("Done")], + ]) + result = client.responses("What?") + assert result["object"] == "response" and result["status"] == "completed" + assert "Done" in json.dumps(result["output"]) + assert bridge.calls == [("get_document", {"doc_name": "r.pdf"})] + assert "CLOUD LIVE INSTRUCTIONS" in fake.instructions[0] + assert fake_model.state["protocols"][0][0] == "responses" + + +@needs_anthropic +def test_bridge_messages_lane_runs_cloud_tools(bridge_client, fake_anthropic): + """The Messages door on a bridge client: cloud MCP tools on the wire, + live instructions in the cached system prefix.""" + client, bridge = bridge_client + calls = fake_anthropic([ + _anthropic_message([{"type": "text", "text": "The answer"}], + "end_turn"), + ]) + result = client.messages("What?", model="claude-test", max_tokens=64) + assert result["content"][0]["text"] == "The answer" + wire = calls[0] + assert wire["tools"][0]["name"] == "get_document" + system_text = json.dumps(wire["system"]) + assert "CLOUD LIVE INSTRUCTIONS" in system_text + assert "READING WORKFLOW" not in system_text + + +@needs_agents +def test_bridge_openai_agent_config_carries_configured_model(bridge_client): + """A bridge client's openai_agent_config carries its chat_model — + same semantics as local; a plain cloud client still omits model.""" + client, _ = bridge_client + config = client.openai_agent_config() + assert config["model"] == "fake-model" + assert "CLOUD LIVE INSTRUCTIONS" in config["instructions"]