fix(chat): expose typed request validation errors by BukeLy · Pull Request #578 · 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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ print(answer)

Configure other models, streaming, multi-document search, citations, and more.

### Chat errors

`PageIndexValidationError` subclasses `PageIndexAPIError` for chat arguments
rejected by the SDK's local checks, across all three protocols and the answer
lane. Catch it before `PageIndexAPIError`, with stream iteration inside the
`try` block. Branch on the exception type, not its message or a missing status.

Provider and tool failures retain their available `status_code` and wrapped
`__cause__`; provider-side request rejections remain provider errors.
Validation messages may echo caller input: display them only to users allowed
to see it. Execution messages, causes, and tracebacks may contain provider or
document details; keep them in private diagnostics and show a generic failure
message to other users. Error text is not a stable or redacted API.

### [Integrate PageIndex with your own agent →](https://docs.pageindex.ai/sdk/agents)

Drop PageIndex tools into the OpenAI Agents SDK, the Claude Agent SDK, or any other framework.
Expand Down
4 changes: 2 additions & 2 deletions pageindex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from .chat_stream import ChatStream
from .client import PageIndexClient, PageIndexCloudClient, PageIndexLocalClient
from .errors import PageIndexAPIError
from .errors import PageIndexAPIError, PageIndexValidationError
from .types import (ChatConfig, ChatProcessOptions, CloudIndexConfig,
IndexConfig, LocalIndexConfig)

Expand All @@ -16,7 +16,7 @@

__all__ = [
"PageIndexClient", "PageIndexCloudClient", "PageIndexLocalClient",
"PageIndexAPIError",
"PageIndexAPIError", "PageIndexValidationError",
"IndexConfig", "CloudIndexConfig", "LocalIndexConfig", "ChatConfig",
"ChatProcessOptions", "ChatStream",
"page_index", "page_index_main", "page_index_flash",
Expand Down
37 changes: 21 additions & 16 deletions pageindex/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

import requests

from .errors import PageIndexAPIError
from .errors import PageIndexAPIError, PageIndexValidationError
from .mcp_bridge import render_prompt_text, render_text

TOOL_RESPONSE_CHAR_LIMIT = 100_000
Expand Down Expand Up @@ -1510,8 +1510,14 @@ def _cloud_bridge(client, gated: bool = False):
def _require_doc_selection(doc_ids) -> None:
"""An empty selection fails loud: washed to None it would mean
"everything", while the tool-layer allowlist would mean "nothing"."""
if doc_ids is not None and not doc_ids:
raise PageIndexAPIError(
if doc_ids is None:
return
if not (isinstance(doc_ids, str) or isinstance(doc_ids, list)
and all(isinstance(doc_id, str) and doc_id for doc_id in doc_ids)):
raise PageIndexValidationError(
"doc_id must be a string or a list of non-empty strings.")
if not doc_ids:
raise PageIndexValidationError(
Comment thread
BukeLy marked this conversation as resolved.
"doc_id is empty. Pass one or more document IDs, or omit "
"doc_id to give the agent the whole library.")

Expand Down Expand Up @@ -1697,11 +1703,8 @@ def doc_targeting_block(client, doc_id) -> Optional[str]:
hands it to callers who own the conversation."""
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.")
_require_doc_selection(doc_id)
doc_ids = [doc_id] if isinstance(doc_id, str) else list(doc_id)
_require_doc_selection(doc_ids)
details = []
missing = []
for one_id in doc_ids:
Expand Down Expand Up @@ -1732,22 +1735,24 @@ def doc_targeting_block(client, doc_id) -> Optional[str]:
)


def _require_folder_selection(client, folder_id) -> None:
if folder_id is not None and not isinstance(folder_id, str):
raise PageIndexValidationError("folder_id must be a string.")
if folder_id not in (None, "", "root") and not getattr(client, "api_key", None):
raise PageIndexValidationError(
"folder_id is cloud-only — folders are not supported in local "
"mode. Create the client with an api_key to use folders.")


def folder_targeting_block(client, folder_id) -> Optional[str]:
"""The folder_id targeting text, rendered as the cloud's managed chat
renders its own: the folder's name and metadata and the directive to
discover its documents there. None for no folder — None, "", and
"root", the library itself, which the managed chat leaves untargeted.
A folder proper is cloud-only: local libraries have none."""
if folder_id is None:
_require_folder_selection(client, folder_id)
if folder_id in (None, "", "root"):
return None
if not isinstance(folder_id, str):
raise PageIndexAPIError("folder_id must be a string.")
if folder_id in ("", "root"):
return None
if not getattr(client, "api_key", None):
raise PageIndexAPIError(
"folder_id is cloud-only — folders are not supported in local "
"mode. Create the client with an api_key to use folders.")
folders = client.list_folders().get("folders") or []
folder = next((f for f in folders if f.get("id") == folder_id), None)
if folder is None:
Expand Down
79 changes: 43 additions & 36 deletions pageindex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
Optional, Union, cast, overload)

from .chat_stream import ChatStream
from .errors import PageIndexAPIError
from .errors import PageIndexAPIError, PageIndexValidationError


_litellm_preload_started = False
Expand Down Expand Up @@ -120,7 +120,7 @@ def _claude_wire(model, surface: str) -> "tuple[str, str]":
no model: refused, so no surface ships model='' or switches a
transport with no model chosen."""
if not isinstance(model, str):
raise PageIndexAPIError(
raise PageIndexValidationError(
f"{surface} model must be a str, got {type(model).__name__}.")
wire = model.removeprefix("litellm/")
for route in _CLAUDE_ROUTES:
Expand All @@ -130,7 +130,7 @@ def _claude_wire(model, surface: str) -> "tuple[str, str]":
else:
wire, route = wire.removeprefix("anthropic/"), "anthropic"
if not wire:
raise PageIndexAPIError(
raise PageIndexValidationError(
f"{surface} model {model!r} names a route but no model id.")
return wire, route

Expand All @@ -142,10 +142,10 @@ def _yaml_names_chat(loader) -> bool:
for key in ("chat_model", "retrieve_model", "model"))


def _needs_model(surface: str) -> PageIndexAPIError:
def _needs_model(surface: str) -> PageIndexValidationError:
# The stock chat_model default is not the user's choice: never send
# it on an Anthropic-native surface as if it were one.
return PageIndexAPIError(
return PageIndexValidationError(
f"{surface} needs a model — pass a Claude model=..., or "
"configure chat_model on the client.")

Expand Down Expand Up @@ -694,11 +694,11 @@ def _require_own_chat(self, lane: str) -> None:
if self._local_chat:
return
if not getattr(self, "api_key", None):
raise PageIndexAPIError(
raise PageIndexValidationError(
"chat_model is empty — it configures nothing, and a local "
"client has no managed chat to fall back to. Set "
"chat_model=... to run the agent with your own model.")
raise PageIndexAPIError(
raise PageIndexValidationError(
f"{lane} drives your own chat model — construct the client "
"with chat_model=... (or a chat= model); the managed cloud chat "
"serves the answer lane and chat(protocol=\"chat_completions\") "
Expand Down Expand Up @@ -1367,14 +1367,14 @@ def chat(
"""
if protocol not in (None, "chat_completions", "responses",
"messages"):
raise PageIndexAPIError(
raise PageIndexValidationError(
"protocol selects the wire: \"chat_completions\" (OpenAI Chat "
"Completions), \"responses\" (OpenAI Responses) or "
"\"messages\" (Anthropic Messages), or leave it unset for "
f"the answer lane — got {protocol!r}.")
if (protocol is not None and show_process is not False
and show_process is not None):
raise PageIndexAPIError(
raise PageIndexValidationError(
"show_process weaves the answer lane's run; with "
f"protocol={protocol!r} the return is the protocol's own "
"envelope and events — drop show_process, or drop "
Expand All @@ -1383,19 +1383,24 @@ def chat(
from .local_chat import _process_options
_process_options(show_process) # a bad value chokes first
if not stream:
raise PageIndexAPIError(
raise PageIndexValidationError(
"show_process shows the run as it happens and requires "
"stream=True; only show_process=False (or None) means "
f"off — got {show_process!r}.")
if isinstance(instructions, list) and protocol != "messages":
raise PageIndexAPIError(
raise PageIndexValidationError(
Comment thread
BukeLy marked this conversation as resolved.
"instructions blocks are the Messages protocol's shape — "
"with protocol=\"messages\" they append after the managed "
"system blocks; the other lanes take a string.")
from .local_chat import _refuse_skeleton
if instructions is not None and not isinstance(instructions, (str, list)):
raise PageIndexValidationError(
"instructions must be a string, or a list of system blocks "
"with protocol=\"messages\".")
from .local_chat import _refuse_skeleton, _validate_model_backend
_refuse_skeleton(extra_body)
if citations and citations is not True:
raise PageIndexAPIError(
_validate_model_backend(model, backend)
if not isinstance(citations, bool):
raise PageIndexValidationError(
"citations must be True or False — for another format pass "
"citation_prompt(format=...) as instructions= (own-model "
"chat).")
Expand All @@ -1413,29 +1418,24 @@ def chat(
enable_citations = True
if protocol in ("responses", "messages"):
self._require_own_chat(f"chat(protocol={protocol!r})")
body = extra_body
if reasoning_effort:
key = "reasoning" if protocol == "responses" else "output_config"
given = extra_body or {}
overrides = given.get(key, {})
if not isinstance(overrides, Mapping):
raise PageIndexValidationError(
f"extra_body[{key!r}] must be a dict when merging "
"reasoning_effort.")
# Caller fields win over the named effort on either wire.
body = {**given, key: {"effort": reasoning_effort, **overrides}}
if protocol == "responses":
body = extra_body
if reasoning_effort:
# OpenAI's own effort field, beside the caller's other
# reasoning keys — theirs still win.
given = extra_body or {}
body = {**given, "reasoning": {
"effort": reasoning_effort,
**given.get("reasoning", {})}}
return self._responses(
messages, model=model, stream=stream, doc_id=doc_id,
folder_id=folder_id,
instructions=cast(Optional[str], instructions),
max_turns=max_turns, extra_body=body,
extra_headers=extra_headers, backend=backend)
body = extra_body
if reasoning_effort:
# Anthropic's own effort field, beside the caller's other
# output_config keys — theirs still win.
given = extra_body or {}
body = {**given, "output_config": {
"effort": reasoning_effort,
**given.get("output_config", {})}}
return self._messages(
messages, model=model, stream=stream, doc_id=doc_id,
folder_id=folder_id,
Expand All @@ -1444,14 +1444,15 @@ def chat(
if instructions:
if isinstance(messages, str):
if not messages.strip():
raise PageIndexAPIError(
raise PageIndexValidationError(
"messages must be a non-empty string or a list of "
"message dicts.")
messages = [{"role": "user", "content": messages}]
# The first system text: managed prompt, then instructions,
# then the history's own system rows.
from .local_chat import _chat_messages
messages = [{"role": "system", "content": instructions},
*messages]
*_chat_messages(messages)]
if protocol == "chat_completions":
return self.chat_completions(
messages, stream=stream, stream_metadata=True, doc_id=doc_id,
Expand Down Expand Up @@ -1622,12 +1623,18 @@ def chat_completions(
"""
if isinstance(messages, str):
if not messages.strip():
raise PageIndexAPIError(
raise PageIndexValidationError(
Comment thread
BukeLy marked this conversation as resolved.
"messages must be a non-empty string or a list of "
"message dicts.")
messages = [{"role": "user", "content": messages}]
from .local_chat import _refuse_skeleton
from .local_chat import (_chat_messages, _refuse_skeleton,
_validate_model_backend)
messages = _chat_messages(messages)
_refuse_skeleton(extra_body)
_validate_model_backend(model, backend)
Comment thread
BukeLy marked this conversation as resolved.
from .agent_tools import _require_doc_selection, _require_folder_selection
_require_doc_selection(doc_id)
_require_folder_selection(self, folder_id)
if self._local_chat:
from .local_chat import run_chat_completions
return run_chat_completions(
Expand All @@ -1640,14 +1647,14 @@ def chat_completions(
extra_headers=extra_headers, backend=backend,
)
if not getattr(self, "api_key", None):
raise PageIndexAPIError(
raise PageIndexValidationError(
"chat_model is empty — it configures nothing, and a local "
"client has no managed chat to fall back to. Set "
"chat_model=... to run the agent with your own model.")
if (model or max_turns is not None or top_p is not None
or max_tokens is not None or reasoning_effort
or extra_headers or backend):
raise PageIndexAPIError(
raise PageIndexValidationError(
Comment thread
BukeLy marked this conversation as resolved.
"model, max_turns, top_p, max_tokens, reasoning_effort, "
"extra_headers and backend drive your own chat "
"model, which this client does not configure — construct "
Expand Down
4 changes: 4 additions & 0 deletions pageindex/errors.py
Loading
Loading