From 8e88cad7aebc980bdb452c41cbe5c670272be30a Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 13:53:00 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20conformant=20responses()=20envelope?= =?UTF-8?q?=20=E2=80=94=20official=20output,=20transcript=20in=20items,=20?= =?UTF-8?q?full=20usage=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - output now carries only model-produced items, so the envelope parses with the official openai SDK types (function_call_output is input vocabulary — the real API never returns it in output) - the full process transcript moves to the new items field; round-trip appends items instead of output (same bytes, so the provider prompt-cache prefix contract is unchanged) - usage aggregates token details across turns (cached_tokens, cache_write_tokens, reasoning_tokens) on both OpenAI surfaces — cache hits are now observable instead of discarded - streaming stops synthesizing the nonstandard tool-output event; every stream event now validates against the official event union, tool results arrive in the terminal envelope's items - tests: two conformance tests pin the contract (non-stream model_validate + per-event stream validation); round-trip prefix tests append items Verified: 267 tests green; live A/B against the real OpenAI API — field-identical to the official hand-rolled flow, round-trip accepted with zero repeat tool calls. --- pageindex/client.py | 20 ++++----- pageindex/local_chat.py | 80 ++++++++++++++++++++++-------------- tests/test_local_chat.py | 87 ++++++++++++++++++++++++++++++---------- 3 files changed, 126 insertions(+), 61 deletions(-) diff --git a/pageindex/client.py b/pageindex/client.py index e105dddce..d6351ad7e 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 diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index ca94a7d1f..082752861 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``; @@ -366,10 +366,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} @@ -515,18 +542,21 @@ 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: + # function_call_output is input vocabulary — the official response + # shape does not admit it in ``output``. The conformant ``output`` + # keeps the model-produced items; the full transcript (the + # round-trip payload) rides in ``items``. 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,8 +587,8 @@ 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 @@ -576,9 +606,9 @@ 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. + # Tool outputs are not output items — they ride only in the + # envelope's ``items``. output_offset = 0 completed = False try: @@ -602,16 +632,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 @@ -630,14 +650,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) diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index a215baf71..b05a7475c 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,18 @@ 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 + # Conformant output (model items only); the full transcript in items. + 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 +322,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 +342,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 +374,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 +396,63 @@ 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 + # Tool outputs are not stream events (official vocabulary only) — they + # arrive in the terminal envelope's items. + 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 +696,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 From 62f8f2878e9766644110355ddb07a47e454322ae Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 13:57:45 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20stale=20anthropic>=3D0.84.0=20hints?= =?UTF-8?q?=20=E2=80=94=20the=20supported=20floor=20is=200.108.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyproject raised the floor in f58cca1 (0.84-0.107 execute a refusal turn's tool_use blocks); the three user-facing strings still pointed hand-installers at the broken range. --- pageindex/client.py | 2 +- pageindex/integrations/anthropic_sdk.py | 2 +- pageindex/local_chat.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pageindex/client.py b/pageindex/client.py index d6351ad7e..4fbc9ad8a 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -750,7 +750,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/local_chat.py b/pageindex/local_chat.py index 082752861..91fba3ae3 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -677,7 +677,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 From 242f9fb87c96adf491d8791f46b53c5ba86cc583 Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 14:00:04 +0800 Subject: [PATCH 3/7] docs: disclose the bridge's binary-stub behavior on the two image-advertising tool surfaces as_openai_tools / as_anthropic_tools cloud docstrings advertised the image tool without mentioning that the in-process bridge replaces base64 payloads with text placeholder stubs (mcp_bridge call_tool). --- pageindex/client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pageindex/client.py b/pageindex/client.py index 4fbc9ad8a..eebd4e7bc 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -637,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 @@ -742,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 From 5ee274946609c8011de5c73b2353ceeb9576618d Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 14:01:16 +0800 Subject: [PATCH 4/7] refactor: trim the envelope-change comments to the essential constraint --- pageindex/local_chat.py | 8 ++------ tests/test_local_chat.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index 91fba3ae3..4ccb379b0 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -543,10 +543,8 @@ def run_responses(client, input, model: Optional[str] = None, from agents.exceptions import AgentsException, MaxTurnsExceeded def envelope(transcript: list, raw_responses) -> dict: - # function_call_output is input vocabulary — the official response - # shape does not admit it in ``output``. The conformant ``output`` - # keeps the model-produced items; the full transcript (the - # round-trip payload) rides in ``items``. + # The official output shape admits no function_call_output; the + # round-trip transcript rides in items. return { "id": f"resp_{uuid.uuid4().hex}", "object": "response", @@ -607,8 +605,6 @@ async def agen(): # 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. - # Tool outputs are not output items — they ride only in the - # envelope's ``items``. output_offset = 0 completed = False try: diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index b05a7475c..1022f83e2 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -300,7 +300,6 @@ def test_responses_end_to_end(client, store_path, fake_model): "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 30} assert fake_model.state["protocols"][0][0] == "responses" - # Conformant output (model items only); the full transcript in items. assert [item.get("type", "message") for item in result["output"]] == [ "function_call", "message"] assert [item.get("type", "message") for item in result["items"]] == [ @@ -396,8 +395,7 @@ 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 outputs are not stream events (official vocabulary only) — they - # arrive in the terminal envelope's items. + # Tool outputs arrive only in the terminal envelope's items. assert not [event for event in events if event.get("item", {}).get("type") == "function_call_output"] assert types[-1] == "response.completed" From 54fd355d76cab31d4ae24bec765f304b361e111b Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 14:09:47 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20declare=20the=20real=20python=20floo?= =?UTF-8?q?r=20=E2=80=94=20>=3D3.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit litellm's stable channel (every release satisfying our >=1.84.0 floor) and both agent extras require 3.10; on 3.9 pip resolution fails on the hard deps (verified in a clean venv — zero packages install). A clean 3.10 venv with all three extras runs the full suite green. CI already tests 3.10/3.13 only. The >=3.7 claim was inherited from the two-dep 0.2.8 client and was already unsatisfiable then (openai>=1.70 needs 3.8). Closes recurring review finding #10. --- pyproject.toml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ea877290..e65a989a6 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,8 @@ include = [ exclude = ["pageindex/flash/assets"] [tool.poetry.dependencies] -python = ">=3.7" +# litellm's stable channel and both agent extras require 3.10. +python = ">=3.10" requests = ">=2.28.0" openai = ">=1.70.0" litellm = ">=1.84.0" From 3c65db535b8b753d3fdf38f0212185d09e8ad0cd Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 14:21:48 +0800 Subject: [PATCH 6/7] chore: trim rationale comments from this session's commits --- pageindex/local_chat.py | 8 ++------ pyproject.toml | 1 - tests/test_local_chat.py | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/pageindex/local_chat.py b/pageindex/local_chat.py index 4ccb379b0..434bdf5ae 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -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)} @@ -418,8 +417,7 @@ 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. + # Strip routing prefixes — report the name the provider serves. reported_model = _reported_model(model_name) managed = _managed_instructions(system_texts) agent = _openai_agent(client, "chat", model_name, managed, @@ -543,8 +541,6 @@ def run_responses(client, input, model: Optional[str] = None, from agents.exceptions import AgentsException, MaxTurnsExceeded def envelope(transcript: list, raw_responses) -> dict: - # The official output shape admits no function_call_output; the - # round-trip transcript rides in items. return { "id": f"resp_{uuid.uuid4().hex}", "object": "response", diff --git a/pyproject.toml b/pyproject.toml index e65a989a6..c316451b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ include = [ exclude = ["pageindex/flash/assets"] [tool.poetry.dependencies] -# litellm's stable channel and both agent extras require 3.10. python = ">=3.10" requests = ">=2.28.0" openai = ">=1.70.0" diff --git a/tests/test_local_chat.py b/tests/test_local_chat.py index 1022f83e2..37553b27f 100644 --- a/tests/test_local_chat.py +++ b/tests/test_local_chat.py @@ -395,7 +395,6 @@ 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 outputs arrive only in the terminal envelope's items. assert not [event for event in events if event.get("item", {}).get("type") == "function_call_output"] assert types[-1] == "response.completed" From 9485de6b2730ccb6cf9616f5bb5c2e6235140fa5 Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 13 Aug 2026 14:34:45 +0800 Subject: [PATCH 7/7] chore: trim non-essential comments across the PR 53 comment lines removed: rationale that belongs in commit messages, descriptions restating what adjacent code or function names already show, and cloud-implementation provenance notes. Section headers and constraint comments (protocol invariants, safety guards) kept. --- pageindex/agent_tools.py | 39 ---------------------- pageindex/integrations/claude_agent_sdk.py | 2 -- pageindex/integrations/openai_agents.py | 2 -- pageindex/local_api.py | 2 -- pageindex/local_chat.py | 8 ----- 5 files changed, 53 deletions(-) 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/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 434bdf5ae..3dddcf18c 100644 --- a/pageindex/local_chat.py +++ b/pageindex/local_chat.py @@ -417,7 +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 - # Strip routing prefixes — report the name the provider serves. reported_model = _reported_model(model_name) managed = _managed_instructions(system_texts) agent = _openai_agent(client, "chat", model_name, managed, @@ -584,10 +583,6 @@ def envelope(transcript: list, raw_responses) -> dict: 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"} @@ -631,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(