diff --git a/pageindex/agent_tools.py b/pageindex/agent_tools.py index 412b8bb8d..1c419bff9 100644 --- a/pageindex/agent_tools.py +++ b/pageindex/agent_tools.py @@ -1486,8 +1486,8 @@ def _require_doc_selection(doc_ids) -> None: def _require_local_scope(client, doc_ids) -> None: - """The allowlist is enforced in-process; cloud lookups run server-side, - so accepting doc_ids there would be advisory-only — refuse loudly.""" + """The allowlist is enforced in-process; cloud tools take none, so + accepting doc_ids there would be advisory-only — refuse loudly.""" _require_doc_selection(doc_ids) if doc_ids is not None and getattr(client, "api_key", None): raise PageIndexAPIError( diff --git a/pageindex/client.py b/pageindex/client.py index 2fc4b2c79..f6b9c77f9 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -6,7 +6,7 @@ import threading import time import warnings -from typing import Any, Callable, Iterator, Optional, Union, cast +from typing import Any, Callable, Iterator, Mapping, Optional, Union, cast from .errors import PageIndexAPIError @@ -78,7 +78,7 @@ def _env_cloud_key(spelling: str, inline: str = "api_key=...") -> str: return key -# One argument vocabulary regardless of spelling: every value is shape- +# One argument vocabulary regardless of spelling: these values are 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, ...]]" = { @@ -338,15 +338,15 @@ def __init__( self, api_key: Optional[str] = None, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, mode: Optional[str] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, model: Optional[str] = None, summary_model: Optional[str] = None, retrieve_model: Optional[str] = None, - storage_path: Optional[str] = None, + storage_path: Optional[Union[str, os.PathLike[str]]] = None, index_backend: Optional[dict[str, Any]] = None, chat_backend: Optional[dict[str, Any]] = None, ): @@ -915,6 +915,11 @@ def chat_completions( reasoning_effort=reasoning_effort, extra_body=extra_body, extra_headers=extra_headers, backend=backend, ) + if not getattr(self, "api_key", None): + raise PageIndexAPIError( + "chat_model is empty — it configures nothing, and a local " + "client has no managed chat to fall back to. Set " + "chat_model=... to run the agent with your own model.") if (model is not None or max_turns is not None or top_p is not None or max_tokens is not None or reasoning_effort is not None or extra_body is not None or extra_headers is not None @@ -1249,8 +1254,9 @@ def as_openai_tools(self, include_management: bool = False, def _local_doc_scope(self, doc_id): """doc_id for the tool layer: passed through locally (structural - allowlist), dropped on cloud where scoping is server-side and the - config helpers keep prompt-level targeting.""" + allowlist), dropped on cloud — its tools take no allowlist, so + own-model chat and the config helpers target at the prompt level + only.""" from .agent_tools import _require_doc_selection _require_doc_selection(doc_id) if not getattr(self, "api_key", None): @@ -1612,8 +1618,8 @@ def __init__( self, api_key: Optional[str] = None, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, chat_model: Optional[str] = None, retrieve_model: Optional[str] = None, chat_backend: Optional[dict[str, Any]] = None, @@ -1642,14 +1648,14 @@ class PageIndexLocalClient(PageIndexClient): def __init__( self, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, model: Optional[str] = None, summary_model: Optional[str] = None, retrieve_model: Optional[str] = None, - storage_path: Optional[str] = None, + storage_path: Optional[Union[str, os.PathLike[str]]] = None, index_backend: Optional[dict[str, Any]] = None, chat_backend: Optional[dict[str, Any]] = None, ): diff --git a/pageindex/types.py b/pageindex/types.py index ee0c44902..b02962899 100644 --- a/pageindex/types.py +++ b/pageindex/types.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import os from typing import Literal, TypedDict, Union PAGEINDEX_CLOUD = "pageindex-cloud" @@ -32,7 +33,7 @@ class LocalIndexConfig(TypedDict, total=False): model: str summary_model: str backend: dict - storage_path: str + storage_path: Union[str, os.PathLike[str]] class ChatConfig(TypedDict, total=False): diff --git a/pageindex/utils.py b/pageindex/utils.py index 1ad54b1b9..47920a029 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -11,7 +11,7 @@ import asyncio from io import BytesIO from dotenv import find_dotenv, load_dotenv -load_dotenv(find_dotenv(usecwd=True) or None) +load_dotenv(find_dotenv(usecwd=True)) import logging import yaml from pathlib import Path diff --git a/tests/test_client.py b/tests/test_client.py index b965a82b6..06860267c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -396,6 +396,31 @@ def test_env_key_found_from_cwd(tmp_path): assert out.stdout.strip() == "ok" +def test_env_not_loaded_from_install_dir(tmp_path, tmp_path_factory): + """The cwd search finding nothing must end the search: find_dotenv + returns '' then, and `or None` handed load_dotenv its own upward walk + from utils.py — the install-dir leak the cwd search replaced. A + symlinked package puts utils.py under a tree whose root holds a .env; + the cwd tree holds none.""" + site = tmp_path / "site" + site.mkdir() + (site / "pageindex").symlink_to(Path(__file__).parent.parent / "pageindex") + (tmp_path / ".env").write_text("PAGEINDEX_API_KEY=pi-leaked\n") + cwd = tmp_path_factory.mktemp("elsewhere") + (cwd / "app.py").write_text( + "from pageindex import PageIndexCloudClient, PageIndexAPIError\n" + "try:\n" + " print(PageIndexCloudClient().api_key)\n" + "except PageIndexAPIError:\n" + " print('unset')\n") + env = {**os.environ, "PYTHONPATH": str(site)} + env.pop("PAGEINDEX_API_KEY", None) + out = subprocess.run([sys.executable, "app.py"], cwd=cwd, env=env, + capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() != "pi-leaked", out.stdout + + 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 @@ -1916,6 +1941,16 @@ def test_blank_chat_model_assignment_stays_managed(): assert not client._local_chat +def test_local_client_blank_chat_model_refuses_at_chat_door(local_client): + """A local client has no managed chat to fall back to: with chat_model + blanked, chat_completions() must refuse as a PageIndexAPIError, not + surface LocalAPI's missing chat_completions as an AttributeError.""" + for blank in ("", " ", None): + local_client.chat_model = blank + with pytest.raises(PageIndexAPIError, match="chat_model is empty"): + local_client.chat_completions("hi") + + 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."""