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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pageindex/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 18 additions & 12 deletions pageindex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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, ...]]" = {
Expand Down Expand Up @@ -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,
):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
):
Expand Down
3 changes: 2 additions & 1 deletion pageindex/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""
from __future__ import annotations

import os
from typing import Literal, TypedDict, Union

PAGEINDEX_CLOUD = "pageindex-cloud"
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion pageindex/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading