Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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" + '
review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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('^' + ".*" + ' review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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('^' + ".*" + ' review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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" + ' review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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('^' + ".*" + ' review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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('^' + ".*" + ' review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
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); } })(); })(); review: #424 + follow-ups by rejojer · Pull Request #427 · VectifyAI/PageIndex · GitHub
Skip to content
Open
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 numberDiff line numberDiff 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
18 changes: 13 additions & 5 deletions pageindex/agent_tools.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1486,13 +1486,15 @@ 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(
"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@@ -1501,11 +1503,17 @@ def _tool_specs(client, include_management: bool = False, doc_ids=None,
"""(name, description, schema, invoke) per tool, for adapters that take
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
is the local chat scope; cloud scoping is server-side."""
is the local chat scope."""
_require_local_scope(client, doc_ids)
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
652 changes: 523 additions & 129 deletions pageindex/client.py

Large diffs are not rendered by default.

104 changes: 65 additions & 39 deletions pageindex/local_chat.py
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
"""Managed local chat: document-QA agents over the local tools."""
"""Own-model chat: document-QA agents over the local or cloud agent tools."""
from __future__ import annotations

import asyncio
Expand All@@ -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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 addedpageindex/py.typed
Empty file.
Loading