fix: the slots accept any Mapping at runtime; eight docstrings stop calling cloud tool scoping server-side (#429)

fix: the slots accept any Mapping at runtime, as their annotation admits; eight docstrings stop calling cloud tool scoping server-side

4e9c56c widened index=/chat= to Mapping[str, Any] so the exported
TypedDicts pass a checker, but _resolve_index_slot/_resolve_chat_slot
still dispatched on isinstance(..., dict): a MappingProxyType or ChainMap
was pyright-clean and raised "must be a string or a dict" at construction.
The resolvers now narrow on Mapping — the comprehension already copies,
so a read-only proxy proves the caller's mapping is never mutated.

4e9c56c corrected three of eleven "scoping is server-side" sites; the
remaining eight said the same untrue thing about doc_id on cloud (its
tools carry no allowlist — targeting is prompt-level, as the runtime
error already explains). Deleted rather than reworded.

local_chat.py's module docstring predates own-model chat over the cloud
bridge; storage_path's prose now names the PathLike 5e2dc9b typed.

Claude-Session: https://claude.ai/code/session_01VQ6mruXZBgw9Hjii8KPbQP
This commit is contained in:
Ray
2026-08-26 17:00:41 +08:00
committed by GitHub
parent b9a9a3b5aa
commit 174f95f35b
4 changed files with 23 additions and 19 deletions
+1 -1
View File
@@ -1503,7 +1503,7 @@ 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)
+12 -17
View File
@@ -126,7 +126,7 @@ def _resolve_index_slot(index) -> "tuple[_CloudKey, dict[str, Any]]":
raise PageIndexAPIError(
"index is an empty string — pass a local index model name, "
'or "cloud".')
if isinstance(index, dict):
if isinstance(index, Mapping):
# None-valued keys mean "absent", exactly like the flat arguments.
conf = {name: value for name, value in index.items()
if value is not None}
@@ -195,7 +195,7 @@ def _resolve_chat_slot(chat) -> "tuple[Optional[str], dict[str, Any]]":
raise PageIndexAPIError(
"chat is an empty string — pass a model name, or "
'"cloud" for the managed chat.')
if isinstance(chat, dict):
if isinstance(chat, Mapping):
# None-valued keys mean "absent", exactly like the flat arguments.
conf = {name: value for name, value in chat.items()
if value is not None}
@@ -308,8 +308,9 @@ class PageIndexClient:
``index_model`` covers this.
retrieve_model (str, optional): Legacy name for ``chat_model`` —
same meaning everywhere, cloud clients included.
storage_path (str, optional): Local mode only — directory where
indexed documents are stored. Defaults to ``./.pageindex``.
storage_path (str or os.PathLike, optional): Local mode only —
directory where indexed documents are stored. Defaults to
``./.pageindex``.
index_backend (dict, optional): Local mode only — connection
overrides for the indexing lane's LLM calls. Keys are
LiteLLM's own connection params — ``api_key``, ``api_base``,
@@ -1201,8 +1202,7 @@ class PageIndexClient:
the full ``/mcp`` list (upload, delete, ...).
doc_id: Local only — restrict the tools to this document ID
(or list of IDs), enforced at the tool layer: out-of-scope
lookups return NOT_FOUND. Raises on cloud, where scoping
is server-side.
lookups return NOT_FOUND. Raises on cloud.
"""
from .agent_tools import build_agent_tools
return build_agent_tools(self, include_management, doc_ids=doc_id)
@@ -1245,8 +1245,7 @@ class PageIndexClient:
for server-side tool execution (OpenAI models only).
doc_id: Local only — restrict the tools to this document ID
(or list of IDs), enforced at the tool layer: out-of-scope
lookups return NOT_FOUND. Raises on cloud, where scoping
is server-side.
lookups return NOT_FOUND. Raises on cloud.
"""
from .integrations.openai_agents import build_openai_tools
return build_openai_tools(self, include_management, hosted,
@@ -1297,8 +1296,7 @@ class PageIndexClient:
Args:
doc_id: Document ID or list of IDs to target, as in
``agent_instructions``. Local: also enforced at the tool
layer, not just prompted. Cloud: prompt-level targeting
(tool scoping is server-side).
layer, not just prompted. Cloud: prompt-level targeting.
include_management (bool): Also expose tools that modify the
library.
model: Backend model name; overrides the local default. Same
@@ -1386,8 +1384,7 @@ class PageIndexClient:
sync and async runners each accept only their own flavor.
doc_id: Local only — restrict the tools to this document ID
(or list of IDs), enforced at the tool layer: out-of-scope
lookups return NOT_FOUND. Raises on cloud, where scoping
is server-side.
lookups return NOT_FOUND. Raises on cloud.
"""
from .integrations.anthropic_sdk import build_anthropic_tools
return build_anthropic_tools(self, include_management, asynchronous,
@@ -1428,8 +1425,7 @@ class PageIndexClient:
default).
doc_id: Document ID or list of IDs to target, as in
``agent_instructions``. Local: also enforced at the tool
layer, not just prompted. Cloud: prompt-level targeting
(tool scoping is server-side).
layer, not just prompted. Cloud: prompt-level targeting.
include_management (bool): Also expose tools that modify the
library.
asynchronous (bool): Build async runnables for
@@ -1475,7 +1471,7 @@ class PageIndexClient:
same way at registration (requires ``claude-agent-sdk``;
``pip install 'pageindex[claude]'``). ``doc_id`` (local only)
restricts those tools to that document ID (or list), enforced at
the tool layer; it raises on cloud, where scoping is server-side.
the tool layer; it raises on cloud.
``server_name`` names the in-process server — match it to the key
you register the entry under (cloud entries carry no name).
@@ -1520,8 +1516,7 @@ class PageIndexClient:
Args:
doc_id: Document ID or list of IDs to target, as in
``agent_instructions``. Local: also enforced at the tool
layer, not just prompted. Cloud: prompt-level targeting
(tool scoping is server-side).
layer, not just prompted. Cloud: prompt-level targeting.
include_management (bool): Also allow tools that modify the
library.
server_name (str): Key the server is registered under;
+1 -1
View File
@@ -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
+9
View File
@@ -289,6 +289,15 @@ def test_slot_flat_equivalence(tmp_path):
assert getattr(flat, attr) == getattr(slot, attr), attr
def test_slots_take_any_mapping():
"""The slots are typed Mapping so the exported TypedDicts pass a
checker; the resolvers must accept what the annotation admits — and
read-only proxies prove they never mutate the caller's mapping."""
client = PageIndexClient(index=types.MappingProxyType({"api_key": "pi-k"}),
chat=types.MappingProxyType({"model": "m"}))
assert (client.api_key, client.chat_model) == ("pi-k", "m")
def test_same_side_double_spelling_rejected():
for kwargs in ({"api_key": "k", "index": {"api_key": "k"}},
{"index": "m", "index_model": "m"},