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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike by rejojer · Pull Request #428 · VectifyAI/PageIndex · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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