diff --git a/pageindex/agent_tools.py b/pageindex/agent_tools.py index 98fc5d206..e00eb3ca9 100644 --- a/pageindex/agent_tools.py +++ b/pageindex/agent_tools.py @@ -323,8 +323,6 @@ def _failure(error: str, details: Optional[dict[str, Any]], def _dumps(payload: dict[str, Any]) -> str: - # Compact, matching _serialized_size — so the size budget measures - # what is actually emitted. return json.dumps(payload, ensure_ascii=False) @@ -706,9 +704,6 @@ def _browse_documents(client, folder_id: str = "root", recursive: bool = False, else: scoped = _scope_documents(_all_documents(client), _allowed_ids) window, total = scoped[offset:offset + limit], len(scoped) - # Advance by what actually arrived — a server may cap its page size — - # and treat an absent/None total like _all_documents does: a full - # window means there may be more. window_end = offset + len(window) has_more = bool(window) and (window_end < total if isinstance(total, int) else len(window) == limit) @@ -865,9 +860,6 @@ def _get_document_structure(client, doc_name: str, waited and entry.get("status") != "failed") try: - # Prefer the raw stored tree: its nodes carry start_index/end_index - # like the cloud structure tool, where client.get_tree() drops - # end_index and renames fields. raw_tree = getattr(getattr(client, "_api", None), "raw_tree", None) tree = raw_tree(entry["id"]) if raw_tree is not None else None if tree is None: @@ -1044,8 +1036,6 @@ def _get_page_content(client, doc_name: str, pages: str, if out_of_range: options.insert(0, f"Document has {max_page} pages total - request " f"pages 1-{max_page}") - # Additive, not either/or: a call can both truncate for size and have - # out-of-range pages — hiding either would misreport what was returned. if remaining or out_of_range: parts = [f"Retrieved {len(included)} of {len(requested)} " "requested pages."] @@ -1091,7 +1081,6 @@ def _remove_document(client, doc_names: list[str], "options": ["Copy each name verbatim from a browse_documents() " "response"]}, "INVALID_INPUT") - # A repeated name is one deletion, not a second "failed" row. doc_names = list(dict.fromkeys(doc_names)) if len(doc_names) > 10: return _failure("Maximum 10 documents can be deleted at once", None, @@ -1216,23 +1205,6 @@ def _tool_docstring(description: str, properties: dict[str, Any]) -> str: return "\n".join(lines) -# Local guidance layer: schema STRUCTURE stays byte-identical to the cloud -# contract minus the hidden cloud-only parameters, and description strings -# adapt to the local surface the same way AGENT_INSTRUCTIONS does — guidance -# must not teach capabilities (folders, semantic ranking) or tools -# (search_documents, get_document_image) that do not exist here. Guard -# tests pin structure (contract-minus-hidden equality), tool references -# (the dead-reference test), and capability phrases (the per-docstring -# phrase test) — a contract refresh that reintroduces a cloud-only -# reference fails loudly. - -#: Cloud-only parameters hidden from the local surface — strict-schema -#: frameworks make the dead-end calls inexpressible, and lenient framework -#: argument models drop them before the call (degrading to the bare call). -#: The call_tool path still answers folder_id/sort/query with the guided -#: error envelope; recursive is simply accepted (flattening a folderless -#: library is the identity). Plain functions reject unknown parameters at -#: the Python call boundary. _LOCAL_HIDDEN_PARAMS: dict[str, tuple[str, ...]] = { "browse_documents": ("folder_id", "recursive", "sort", "query"), "get_document": ("folder_id",), @@ -1257,7 +1229,6 @@ def _tool_docstring(description: str, properties: dict[str, Any]) -> str: 'Folder browsing and semantic ranking (sort="relevance") are not ' "supported in local mode yet — they work on PageIndex cloud." ), - # The image sentence points at a tool that is not registered locally. "get_page_content": TOOL_CONTRACT["get_page_content"]["description"] .replace(" Embedded image paths in the response feed into " "`get_document_image()`.", ""), @@ -1536,13 +1507,6 @@ def remove_document(doc_names: list[str]) -> str: # ── agent instructions ── -# Local subset of the cloud MCP server's initialize instructions (its -# no-folders variant), trimmed to what exists here: the search_documents -# escalation steps, get_document_image, and the shared read-only-folders -# block are removed, and the sort="relevance" guidance is replaced with -# name/description matching (semantic ranking is cloud-side). Cloud -# clients receive the server's live instructions instead — see -# _base_instructions(). _INSTRUCTIONS_HEADER = ( "PageIndex by Vectify AI is a document platform for uploading and " @@ -1638,9 +1602,6 @@ def doc_targeting_block(client, doc_id, scoped: bool = False) -> Optional[str]: "and would read the newer one. Rename or remove the " "duplicate, or pass the newer doc_id." ) - # get_document keeps the cloud detail wire shape, which local mode - # serves without the user's metadata tags; the listing carries them - # in both modes. by_id = {doc.get("id"): doc for doc in listing} for one_id, detail in zip(doc_ids, details): if detail.get("metadata") is None: diff --git a/pageindex/client.py b/pageindex/client.py index e105dddce..eebd4e7bc 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -442,11 +442,13 @@ def responses( Document QA over the OpenAI Responses protocol — the agentic surface. Local only for now. Drives your backend's /responses end to end (no - translation layer), so the ``output`` carries the whole process as - standard items — messages, function calls, and function outputs - (the SDK executes the tools). Append the returned ``output`` to your - next call's ``input`` verbatim to keep provider prompt-cache prefix - continuity and the agent's memory of what it already read. + translation layer). The envelope is official Responses shape — + ``output`` carries the model-produced items and parses with the + openai SDK types — and the whole process transcript (including the + tool outputs the SDK executed) rides in the extra ``items`` field. + Append the returned ``items`` to your next call's ``input`` verbatim + to keep provider prompt-cache prefix continuity and the agent's + memory of what it already read. Requires ``pageindex[openai]`` and a backend that supports the Responses API; backends that only speak chat.completions should use @@ -457,15 +459,15 @@ def responses( Args: input: A user message string, or a list of Responses input items - (round-trip prior ``output`` items here). + (round-trip prior ``items`` here). model: Backend model name (defaults to ``retrieve_model``). stream: Yield Responses stream events as dicts — one logical response per call: per-turn backend lifecycle events are collapsed, sequence numbers are reassigned monotonically, and ``output_index`` is re-based onto the single logical - ``output``; tool outputs are emitted as - ``response.output_item.done`` events and the single final - event is the terminal ``response.*`` for the run's status. + ``output``. The single final event is the terminal + ``response.*`` for the run's status; its ``response`` + carries the tool outputs in ``items``. doc_id: Document ID or list of IDs to scope the conversation. Keep it identical across a conversation's calls — the targeting block it adds is re-set each call and is part @@ -635,7 +637,9 @@ def as_openai_tools(self, include_management: bool = False, Cloud (default): the full live read tool set (search, folders, images — as enabled for your key) as plain function tools, discovered from the PageIndex MCP server and executed from your - process — works with any model backend. Pass ``hosted=True`` to + process — works with any model backend. Binary tool results + (e.g. ``get_document_image``) arrive as text placeholder stubs + on this in-process path. Pass ``hosted=True`` to hand the connection to OpenAI instead: one hosted MCP tool, tool calls executed server-side (lowest latency; requires an OpenAI-hosted model on the Responses API). The framework's own @@ -740,7 +744,9 @@ def as_anthropic_tools(self, include_management: bool = False, enabled for your key), discovered from the PageIndex MCP server and executed from your process; the server's input schemas pass through verbatim (MCP and the Messages API share the schema - shape). The server-side alternative is the Messages API's beta + shape), and binary tool results (e.g. ``get_document_image``) + arrive as text placeholder stubs on this in-process path. The + server-side alternative is the Messages API's beta MCP connector — ``mcp_servers=[{"type": "url", "name": "pageindex", "url": f"{BASE_URL}/mcp?tools=read", "authorization_token": }]`` (drop @@ -748,7 +754,7 @@ def as_anthropic_tools(self, include_management: bool = False, tools involved. Local: the in-process tools — the same set ``messages()`` runs internally. - Requires ``anthropic>=0.84.0`` + Requires ``anthropic>=0.108.0`` (``pip install 'pageindex[anthropic]'``), imported only when this method is called. diff --git a/pageindex/integrations/anthropic_sdk.py b/pageindex/integrations/anthropic_sdk.py index 4405df5a1..089b0809f 100644 --- a/pageindex/integrations/anthropic_sdk.py +++ b/pageindex/integrations/anthropic_sdk.py @@ -23,7 +23,7 @@ def build_anthropic_tools(client, include_management: bool = False, except ImportError as exc: raise PageIndexAPIError( "as_anthropic_tools requires the Anthropic SDK tool runner " - "(anthropic>=0.84.0) — pip install -U anthropic (or pip install " + "(anthropic>=0.108.0) — pip install -U anthropic (or pip install " "'pageindex[anthropic]')." ) from exc from ..agent_tools import _tool_specs diff --git a/pageindex/integrations/claude_agent_sdk.py b/pageindex/integrations/claude_agent_sdk.py index f3ab19c9c..8c76cb434 100644 --- a/pageindex/integrations/claude_agent_sdk.py +++ b/pageindex/integrations/claude_agent_sdk.py @@ -16,8 +16,6 @@ def build_claude_mcp(client, include_management: bool = False, doc_ids=None): from ..agent_tools import _require_local_scope - # The cloud branch returns a URL config — reject cloud doc_ids so they - # are never silently dropped. _require_local_scope(client, doc_ids) if getattr(client, "api_key", None): # include_management picks the endpoint — the URL itself is the diff --git a/pageindex/integrations/openai_agents.py b/pageindex/integrations/openai_agents.py index 266f71618..36c062d2f 100644 --- a/pageindex/integrations/openai_agents.py +++ b/pageindex/integrations/openai_agents.py @@ -29,8 +29,6 @@ def build_openai_tools(client, include_management: bool = False, ) from exc from ..agent_tools import (_dumps, _failure, _require_local_scope, _tool_specs) - # The hosted branch returns before _tool_specs — reject cloud doc_ids - # here so they are never silently dropped. _require_local_scope(client, doc_ids) if getattr(client, "api_key", None) and hosted: # include_management picks the endpoint — the URL itself is the diff --git a/pageindex/local_api.py b/pageindex/local_api.py index 9ad909ad0..8b1e6f184 100644 --- a/pageindex/local_api.py +++ b/pageindex/local_api.py @@ -97,8 +97,6 @@ def submit_document( raise PageIndexAPIError( "Failed to submit document: PDF has no content. All pages are blank." ) - # Fail before paying for indexing when _1.._99 are all taken; the - # binding name resolution happens again at save. self._unique_doc_name(os.path.basename(file_path)) try: diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index ca94a7d1f..3dddcf18c 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -2,11 +2,11 @@ Three methods, three backend protocols, routed 1:1: ``chat_completions`` drives the backend's /chat/completions (any OpenAI-compatible backend, -final answer only), ``responses`` drives /responses (process items are -standard output; round-trip them for provider prompt-cache continuation and -agent memory), ``messages`` drives Anthropic's /v1/messages via the SDK's -own tool runner (tool_use/tool_result round-trip is the format's native -behavior). +final answer only), ``responses`` drives /responses (official-shape +envelope; the full process transcript rides in ``items`` — round-trip it +for provider prompt-cache continuation and agent memory), ``messages`` +drives Anthropic's /v1/messages via the SDK's own tool runner +(tool_use/tool_result round-trip is the format's native behavior). Content passes through untouched — the caller's messages, the model's answers, tool outputs. Native stop reasons pass through on ``messages``; @@ -303,8 +303,7 @@ def _conversation_group_id(model_name: str, instructions: str, items) -> str: def _run_kwargs(max_turns, group_id: str) -> dict: - # Managed runs never export traces — the caller opted into document QA, - # not telemetry. + # No traces — the caller opted into QA, not telemetry. from agents import RunConfig kwargs: dict = {"run_config": RunConfig(tracing_disabled=True, group_id=group_id)} @@ -366,10 +365,37 @@ def _wrap_max_turns(max_turns) -> PageIndexAPIError: ) +def _usage_sums(raw_responses) -> "tuple[int, int, int, int, int]": + prompt = completion = cached = cache_write = reasoning = 0 + for r in raw_responses: + prompt += r.usage.input_tokens + completion += r.usage.output_tokens + details = getattr(r.usage, "input_tokens_details", None) + cached += getattr(details, "cached_tokens", 0) or 0 + cache_write += getattr(details, "cache_write_tokens", 0) or 0 + details = getattr(r.usage, "output_tokens_details", None) + reasoning += getattr(details, "reasoning_tokens", 0) or 0 + return prompt, completion, cached, cache_write, reasoning + + def _openai_usage(raw_responses) -> dict: - prompt = sum(r.usage.input_tokens for r in raw_responses) - completion = sum(r.usage.output_tokens for r in raw_responses) + """Cross-turn sums, chat.completions dialect.""" + prompt, completion, cached, _, reasoning = _usage_sums(raw_responses) return {"prompt_tokens": prompt, "completion_tokens": completion, + "total_tokens": prompt + completion, + "prompt_tokens_details": {"cached_tokens": cached}, + "completion_tokens_details": {"reasoning_tokens": reasoning}} + + +def _responses_usage(raw_responses) -> dict: + """Cross-turn sums, Responses dialect.""" + prompt, completion, cached, cache_write, reasoning = ( + _usage_sums(raw_responses)) + return {"input_tokens": prompt, + "input_tokens_details": {"cached_tokens": cached, + "cache_write_tokens": cache_write}, + "output_tokens": completion, + "output_tokens_details": {"reasoning_tokens": reasoning}, "total_tokens": prompt + completion} @@ -391,8 +417,6 @@ def run_chat_completions(client, messages, stream: bool = False, block = _doc_block(client, doc_id) items = ([{"role": "user", "content": block}] if block else []) + history model_name = model or client.retrieve_model - # litellm/ and openai/ are the SDK's routing markers, not model names — - # report the name the provider actually serves. reported_model = _reported_model(model_name) managed = _managed_instructions(system_texts) agent = _openai_agent(client, "chat", model_name, managed, @@ -515,18 +539,17 @@ def run_responses(client, input, model: Optional[str] = None, from agents import Runner from agents.exceptions import AgentsException, MaxTurnsExceeded - def envelope(output: list, raw_responses) -> dict: - usage = _openai_usage(raw_responses) + def envelope(transcript: list, raw_responses) -> dict: return { "id": f"resp_{uuid.uuid4().hex}", "object": "response", "created_at": int(time.time()), "model": _reported_model(model_name), "status": recorded.get("status") or "completed", - "output": output, - "usage": {"input_tokens": usage["prompt_tokens"], - "output_tokens": usage["completion_tokens"], - "total_tokens": usage["total_tokens"]}, + "output": [item for item in transcript + if item.get("type") != "function_call_output"], + "items": transcript, + "usage": _responses_usage(raw_responses), "instructions": managed, "tools": [{"type": "function", "name": tool.name, "description": tool.description, @@ -557,13 +580,9 @@ def envelope(output: list, raw_responses) -> dict: except openai.OpenAIError as exc: raise PageIndexAPIError( f"The model backend failed: {exc}") from exc - output = result.to_input_list()[len(items):] - return envelope(output, result.raw_responses) + transcript = result.to_input_list()[len(items):] + return envelope(transcript, result.raw_responses) - # One logical response per call: per-turn backend lifecycle events - # (created/completed/...) are collapsed — forwarding them verbatim would - # end a canonical consumer at the first turn — and sequence numbers are - # reassigned monotonically across the whole run. lifecycle = {"response.created", "response.in_progress", "response.completed", "response.failed", "response.incomplete", "response.queued"} @@ -576,9 +595,7 @@ async def agen(): # output_index addresses an item's position in the logical # response.output (the final envelope's list). Backend events # carry per-turn indexes that restart at 0 each turn, so they are - # re-based by the count of items already committed by prior turns - # — and the tool outputs the SDK injects between turns take the - # next slot on that same axis. + # re-based by the count of items already committed by prior turns. output_offset = 0 completed = False try: @@ -602,16 +619,6 @@ async def agen(): sequence += 1 data["sequence_number"] = sequence yield data - elif (event.type == "run_item_stream_event" - and event.item.type == "tool_call_output_item"): - # We are the tool executor, so we emit the output item - # the way the platform streams its own server-side tools. - sequence += 1 - yield {"type": "response.output_item.done", - "output_index": output_offset, - "sequence_number": sequence, - "item": dict(event.item.to_input_item())} - output_offset += 1 completed = True except MaxTurnsExceeded as exc: raise _wrap_max_turns(max_turns) from exc @@ -619,9 +626,6 @@ async def agen(): if recorded.get("status") not in ("failed", "incomplete"): raise PageIndexAPIError( f"The agent backend failed: {exc}") from exc - # response.failed / response.incomplete: the engine re-raises - # the backend's terminal state as an exception — it is a - # protocol event, emitted as the terminal event below. completed = True except openai.OpenAIError as exc: raise PageIndexAPIError( @@ -630,14 +634,14 @@ async def agen(): if not completed and hasattr(streamed, "cancel"): streamed.cancel() # abandoned/failed: stop the agent task await _aclose_backend(agent) - output = streamed.to_input_list()[len(items):] + transcript = streamed.to_input_list()[len(items):] sequence += 1 status = recorded.get("status") or "completed" terminal = {"incomplete": "response.incomplete", "failed": "response.failed"}.get(status, "response.completed") yield {"type": terminal, "sequence_number": sequence, - "response": envelope(output, streamed.raw_responses)} + "response": envelope(transcript, streamed.raw_responses)} return _stream_sync(agen) @@ -657,7 +661,7 @@ def _require_anthropic() -> None: from anthropic.lib.tools import ToolError # noqa: F401 except ImportError as exc: raise PageIndexAPIError( - "messages in local mode requires anthropic >= 0.84.0 (the tool " + "messages in local mode requires anthropic >= 0.108.0 (the tool " "runner with ToolError) — pip install -U anthropic." ) from exc diff --git a/pyproject.toml b/pyproject.toml index 9ea877290..c316451b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -28,7 +25,7 @@ include = [ exclude = ["pageindex/flash/assets"] [tool.poetry.dependencies] -python = ">=3.7" +python = ">=3.10" requests = ">=2.28.0" openai = ">=1.70.0" litellm = ">=1.84.0" diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index a215baf71..37553b27f 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -187,7 +187,10 @@ def test_chat_completions_end_to_end(client, store_path, fake_model): "content": "The answer"} assert result["choices"][0]["finish_reason"] == "stop" assert result["usage"] == {"prompt_tokens": 20, "completion_tokens": 10, - "total_tokens": 30} + "total_tokens": 30, + "prompt_tokens_details": {"cached_tokens": 0}, + "completion_tokens_details": + {"reasoning_tokens": 0}} assert fake_model.state["protocols"][0][0] == "chat" # The tool ran for real: turn 2's input carries its output. turn2 = json.dumps(fake.inputs[1]) @@ -290,11 +293,17 @@ def test_responses_end_to_end(client, store_path, fake_model): assert result["id"].startswith("resp_") assert result["object"] == "response" assert result["status"] == "completed" - assert result["usage"] == {"input_tokens": 20, "output_tokens": 10, - "total_tokens": 30} + assert result["usage"] == { + "input_tokens": 20, + "input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0}, + "output_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 30} assert fake_model.state["protocols"][0][0] == "responses" - types = [item.get("type", "message") for item in result["output"]] - assert "function_call" in types and "function_call_output" in types + assert [item.get("type", "message") for item in result["output"]] == [ + "function_call", "message"] + assert [item.get("type", "message") for item in result["items"]] == [ + "function_call", "function_call_output", "message"] # The final item is the assistant answer. assert "The answer" in json.dumps(result["output"][-1]) @@ -312,7 +321,7 @@ def test_responses_round_trip_extends_prefix(client, store_path, fake_model): second = fake_model([[_msg_item("Done")]]) follow_up = ([{"role": "user", "content": "What status?"}] - + result["output"] + + result["items"] + [{"role": "user", "content": "and now?"}]) client.responses(follow_up) previous_final = first.inputs[-1] @@ -332,7 +341,7 @@ def test_responses_round_trip_prefix_with_doc_id(client, store_path, fake_model) second = fake_model([[_msg_item("Done")]]) follow_up = ([{"role": "user", "content": "What status?"}] - + result["output"] + + result["items"] + [{"role": "user", "content": "and now?"}]) client.responses(follow_up, doc_id="pi-a") previous_final = first.inputs[-1] @@ -364,7 +373,7 @@ def spy(max_turns, group_id): fake_model([[_msg_item("c")]]) follow_up = ([{"role": "user", "content": "What is the CAGR?"}] - + result["output"] + + result["items"] + [{"role": "user", "content": "and now?"}]) client.responses(follow_up, doc_id="pi-a") assert keys[2] == keys[0] # a continuation keeps its conversation's key @@ -386,26 +395,61 @@ def test_responses_stream_passthrough(client, store_path, fake_model): events = list(client.responses("q", stream=True)) types = [event.get("type") for event in events] assert "response.output_text.delta" in types - tool_events = [event for event in events - if event.get("type") == "response.output_item.done" - and event.get("item", {}).get("type") - == "function_call_output"] - assert tool_events, types + assert not [event for event in events + if event.get("item", {}).get("type") == "function_call_output"] assert types[-1] == "response.completed" final = events[-1]["response"] assert final["status"] == "completed" assert final["usage"]["total_tokens"] == 30 - # output_index addresses the logical response.output: the tool output - # slots in after turn 1's item, and turn 2's deltas are re-based past - # both instead of restarting at 0. - assert (final["output"][tool_events[0]["output_index"]]["type"] - == "function_call_output") + assert [item.get("type", "message") for item in final["output"]] == [ + "function_call", "message"] + assert [item.get("type", "message") for item in final["items"]] == [ + "function_call", "function_call_output", "message"] + # output_index addresses the logical response.output: turn 2's deltas + # are re-based past turn 1's item instead of restarting at 0. last_delta = [event for event in events if event.get("type") == "response.output_text.delta"][-1] assert (final["output"][last_delta["output_index"]] .get("type", "message") == "message") +@needs_agents +def test_responses_envelope_validates_as_official_response(client, store_path, + fake_model): + """The conformance contract: the envelope parses with the official + openai SDK types, and the transcript survives in the extension field.""" + from openai.types.responses import Response + seed_doc(store_path, "pi-a", "report.pdf") + fake_model([ + [_call_item("get_document", {"doc_name": "report.pdf"})], + [_msg_item("The answer")], + ]) + result = client.responses("What status?") + parsed = Response.model_validate(result) + assert [item.type for item in parsed.output] == ["function_call", + "message"] + assert parsed.model_dump()["items"] == result["items"] + + +@needs_agents +def test_responses_stream_events_validate_as_official_events( + client, store_path, fake_model): + """Every stream event, terminal envelope included, parses with the + official event union.""" + from pydantic import TypeAdapter + from openai.types.responses import ResponseStreamEvent + seed_doc(store_path, "pi-a", "report.pdf") + fake_model([ + [_call_item("get_document", {"doc_name": "report.pdf"})], + [_msg_item("The answer")], + ]) + adapter = TypeAdapter(ResponseStreamEvent) + events = list(client.responses("q", stream=True)) + assert events + for event in events: + adapter.validate_python(event) + + # ── messages (Anthropic engine) ── try: @@ -649,10 +693,6 @@ def test_responses_stream_single_completed_monotonic_sequence( if "sequence_number" in event] assert sequences == sorted(sequences) assert len(set(sequences)) == len(sequences) - tool_done = next(event for event in events - if event.get("type") == "response.output_item.done" - and event["item"]["type"] == "function_call_output") - assert "sequence_number" in tool_done and "output_index" in tool_done @needs_agents