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
5 changes: 4 additions & 1 deletion pageindex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
]
Expand All @@ -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):
Expand Down
12 changes: 10 additions & 2 deletions pageindex/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)


Expand All @@ -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"))
Expand Down
611 changes: 502 additions & 109 deletions pageindex/client.py

Large diffs are not rendered by default.

102 changes: 64 additions & 38 deletions pageindex/local_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 += (
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -844,15 +867,16 @@ 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:
from anthropic import beta_tool # noqa: F401
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

Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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():
Expand All @@ -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():
Expand Down
Empty file added pageindex/py.typed
Empty file.
48 changes: 48 additions & 0 deletions pageindex/types.py
Original file line number Diff line number Diff line change
@@ -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]
4 changes: 2 additions & 2 deletions pageindex/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading