Skip to content

review: the complete v0.2.10 line β€” agent tools, local chat, Flash default - #400

Open
rejojer wants to merge 101 commits into
pre-396-mainfrom
feat/local-chat
Open

review: the complete v0.2.10 line β€” agent tools, local chat, Flash default#400
rejojer wants to merge 101 commits into
pre-396-mainfrom
feat/local-chat

Conversation

@rejojer

@rejojer rejojer commented Aug 12, 2026

Copy link
Copy Markdown
Member

PageIndex SDK 0.2.10 adds two things:

  1. Agent tools β€” plug PageIndex document retrieval into any agent framework with one call.
  2. Local chat β€” ask questions about your documents: client.chat() for the answer, or three standard chat APIs for the full envelopes.

Both work in local mode (runs on your machine, with your model keys) and cloud mode (runs on api.pageindex.ai, with a PageIndex API key).

(Review note: this PR holds the complete 0.2.10 diff for review. The code is already on main via #396, #402, #404, #405, #406, #409, and #410. This PR is not meant to be merged; the tools layer's own review record is #393.)

Install

pip install pageindex==0.2.10.dev5                   # runs everything in this guide

pip install "pageindex[anthropic]==0.2.10.dev5"      # + Anthropic SDK (tool runner, messages())
pip install "pageindex[claude]==0.2.10.dev5"         # + Claude Agent SDK

0.2.10 is a pre-release β€” plain pip install pageindex still resolves 0.2.8, so pin the version. An extra only adds a vendor's own SDK surface; everything else ships with the base install.

Quick start

import os
from pageindex import PageIndexClient

os.environ["OPENAI_API_KEY"] = "your-openai-key"

client = PageIndexClient()                            # local by default; api_key="..." switches to cloud
doc = client.submit_document("report.pdf", wait=True)

answer = client.chat("What was Q3 revenue?", doc_id=doc["doc_id"])
print(answer)

One rule for keys: the key belongs to whatever model does the thinking β€” at indexing time the summary model, at chat time the agent's model. The navigation tools themselves make no LLM calls. A missing key fails fast, naming the variable to set; cloud mode needs only api_key from dash.pageindex.ai, no model keys. (PageIndexLocalClient / PageIndexCloudClient pin the mode explicitly instead of inferring it.)

Two model knobs: index_model (indexing β€” structure and summaries, default gpt-5.6-luna) and chat_model (every chat door, default gpt-5.6-sol), settable as constructor kwargs or in config.yaml. model sets both at once, and the released role names (summary_model, retrieve_model) stay accepted β€” new names win over old, specific over general. Chat-lane model names are LiteLLM's grammar, verbatim: bare names are OpenAI-compatible shorthand (OPENAI_BASE_URL picks the server), provider/model reaches that provider, and no prefix triggers a side lane.

Local indexing defaults to PageIndex Flash β€” the tree comes from the PDF's layout in seconds, the LLM only writes summaries. mode="standard" for the fully LLM-built tree. CLI: python run_pageindex.py --pdf_path doc.pdf. Documents persist under ./.pageindex β€” submit once, then reuse the doc_id (client.list_documents() shows what is stored).

Chat with your documents

chat() β€” question in, answer out

chat() returns just the answer; the agent loop β€” tree navigation, page reads β€” runs inside. It is stateless: you keep the history.

answer = client.chat("What was Q3 revenue?", doc_id=doc["doc_id"])

# Multi-turn β€” pass your own role/content history, keep doc_id the same
answer2 = client.chat(
    [{"role": "user", "content": "What was Q3 revenue?"},
     {"role": "assistant", "content": answer},
     {"role": "user", "content": "And how did it compare to last year?"}],
    doc_id=doc["doc_id"],
)

# Streaming β€” text chunks
for chunk in client.chat("Summarize the risk factors.", doc_id=doc["doc_id"], stream=True):
    print(chunk, end="")

# Any model β€” LiteLLM-style name, with that provider's key set
client.chat("What was Q3 revenue?", doc_id=doc["doc_id"], model="anthropic/claude-sonnet-4-6")

# How hard it thinks β€” unset leaves each model's own default behavior
client.chat("Reconcile the two revenue tables.", doc_id=doc["doc_id"], reasoning_effort="high")

chat()'s knobs stay business-level on purpose β€” who answers (model), about what (doc_id), how hard it thinks (reasoning_effort). Sampling and wire-level controls live on the protocol surfaces.

Three standard chat APIs

chat() is sugar over chat_completions(). When you need the envelope β€” usage accounting, streaming metadata, the tool-use process β€” call a protocol surface. Each speaks one standard wire format, so request and response look exactly like the API you already know; pass a plain string or full messages in that protocol's format.

# OpenAI Chat Completions β€” works on any OpenAI-compatible backend
r = client.chat_completions("What was Q3 revenue?", doc_id=doc["doc_id"])
print(r["choices"][0]["message"]["content"])

# OpenAI Responses β€” the full agentic transcript, built to round-trip
r = client.responses("Which section covers risk factors?", doc_id=doc["doc_id"])
print(r["output"][-1]["content"][0]["text"])

# Anthropic Messages β€” pip install "pageindex[anthropic]", needs ANTHROPIC_API_KEY
r = client.messages("What was Q3 revenue?", doc_id=doc["doc_id"], model="claude-sonnet-4-6")
print(r["content"][-1]["text"])

All three stream with stream=True and do multi-turn the protocol's own way: append the previous reply to your next call's messages. Provider prompt caching keeps working across turns β€” Claude models (Anthropic direct, Bedrock, Vertex) get the managed prefix cache-marked automatically. doc_id targeting is enforced by the tools, not just suggested to the model.

Tuning rides each protocol's own vocabulary, forwarded verbatim with no defaults of ours: reasoning via chat_completions(reasoning_effort=) / responses(reasoning={}) / messages(thinking={}); sampling and output caps via temperature, top_p, and max_tokens (max_output_tokens on responses()) β€” the caps bound each backend call in the agent loop, the way max_turns bounds the loop. Fields a method doesn't name go through extra_body, merged into the request last so caller keys win.

Connection config follows the same doctrine: index_backend / chat_backend on the constructor (plus per-call backend on the protocol doors, per-call keys winning) carry each lane's own connection vocabulary verbatim β€” LiteLLM params on the indexing lane and chat_completions(), openai SDK client params on responses(), anthropic SDK client params on messages() β€” so two clients can point at two providers without environment juggling. Credentials belong there, never in extra_body. extra_headers on all three doors sends raw HTTP headers (beta flags and the like); the one wire-probed exception is documented: LiteLLM's anthropic adapter owns anthropic-beta, so Anthropic beta flags belong on messages().

Bring your own agent framework

One call returns everything the framework needs β€” instructions plus tools. Local and cloud clients work identically. Agent frameworks are async-native, so the snippets assume an async context, with client and doc from the quick start.

OpenAI Agents SDK β€” ships with the SDK

from agents import Agent, Runner

agent = Agent(**client.openai_agent_config())
result = await Runner.run(agent, "What was total revenue this quarter, vs last year?")
print(result.final_output)
More configuration options
agent = Agent(
    name="PageIndex",
    instructions=client.agent_instructions(),   # doc_id targeting goes here
    tools=client.as_openai_tools(),             # include_management=True adds deletion
    model=client.chat_model,                    # local clients only β€” cloud omits it
)

Anthropic SDK tool runner β€” pip install "pageindex[anthropic]"

import anthropic

runner = anthropic.AsyncAnthropic().beta.messages.tool_runner(
    **client.anthropic_runner_config(model="claude-sonnet-4-6", asynchronous=True),
    messages=[{"role": "user", "content": "What was total revenue this quarter?"}],
)
final = await runner.until_done()
print(final.content[-1].text)
More configuration options
runner = anthropic.AsyncAnthropic().beta.messages.tool_runner(
    model="claude-sonnet-4-6",
    max_tokens=8192,                            # default resolved per model
    system=client.agent_instructions(),
    tools=client.as_anthropic_tools(asynchronous=True),
    max_iterations=10,
    messages=[{"role": "user", "content": "What was total revenue this quarter?"}],
)

Claude Agent SDK β€” pip install "pageindex[claude]"

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query

options = ClaudeAgentOptions(**client.claude_agent_config())
async for message in query(prompt="What was total revenue this quarter?", options=options):
    if isinstance(message, ResultMessage):
        print(message.result)
More configuration options
options = ClaudeAgentOptions(
    system_prompt=client.agent_instructions(),
    mcp_servers={"pageindex": client.as_claude_mcp()},
    allowed_tools=["mcp__pageindex"],           # pre-approval; the server itself is gated
)

Any other framework β€” no extras needed

tools = client.agent_tools()   # plain functions returning JSON envelopes

Drop to the explicit calls to customize. All of these accept doc_id=... to point the agent at specific documents, and include_management=True to also expose document deletion (off by default).

What works where

Bring your own agent β€” the tools, on every major surface:

Surface Local Cloud
agent_tools() β€” plain functions, any framework βœ… in-process tools βœ… live cloud tool set over MCP
as_openai_tools() / openai_agent_config() β€” OpenAI Agents SDK βœ… βœ… (hosted=True: execution on OpenAI's side, read-only endpoint by default)
as_anthropic_tools() / anthropic_runner_config() β€” Anthropic SDK tool runner βœ… sync & async βœ… sync & async
as_claude_mcp() / claude_agent_config() β€” Claude Agent SDK / Claude Code βœ… in-process MCP server βœ… remote MCP config β€” read-only endpoint by default
Standard MCP, no SDK involved ⬜ stdio entry point (follow-up) βœ… api.pageindex.ai/mcp (read-only: …/mcp?tools=read) β€” any MCP host, the Anthropic MCP connector, OpenAI hosted MCP

Managed chat β€” the SDK runs the loop:

Method Wire format Engine Local Cloud
chat() answer string out β€” sugar over chat_completions() openai-agents βœ… βœ… hosted endpoint
chat_completions() OpenAI chatcmpl openai-agents βœ… βœ… hosted endpoint
responses() OpenAI Responses openai-agents βœ… ⬜ raises β€” cloud converges toward this later
messages() Anthropic Messages anthropic tool_runner βœ… ⬜ raises

Local serves four read-only tools: browse_documents, get_document, get_document_structure, get_page_content (remove_document only with include_management=True). Cloud adds search_documents, folders, and get_document_image β€” discovered live from the server, never frozen into the SDK.

What a run looks like

An actual run (local mode, OpenAI Agents SDK, over examples/documents/q1-fy25-earnings.pdf):

Q: What was Disney's total revenue in Q1 FY2025, and how did it compare to the prior-year quarter? Cite the page you found it on.

[tool] get_document_structure({"doc_name": "q1-fy25-earnings.pdf", "part": 1})
[tool] get_page_content({"doc_name": "q1-fy25-earnings.pdf", "pages": "1,3"})

A: Disney's total revenue in Q1 FY2025 was $24.7 billion, up 5% from $23.5 billion in the prior-year quarter.
Citations β€” Page 1: "Revenues increased 5% for Q1 to $24.7 billion from $23.5 billion in Q1 fiscal 2024"; Page 3: table shows $24,690M vs $23,549M, +5%.

This is the intended loop: the agent reads the tree structure first, picks tight page ranges, and answers from tool output with page citations. No vector index, no chunking. The retrieval intelligence is your agent's own model β€” the navigation tools make no LLM calls.


Everything below is design rationale and the test record, written for reviewers. You don't need it to use the SDK.

Design β€” the tools layer

  • The tool surface is the cloud MCP contract: browse_documents / get_document / get_document_structure / get_page_content, doc_name-addressed, same input schemas, descriptions, and JSON response envelopes as the hosted MCP server's tools/list β€” agent prompts port unchanged between the cloud MCP connection and these in-process tools. Adapters hand the contract/server schema to the framework verbatim (FunctionTool(params_json_schema=…), beta_tool(input_schema=…)) β€” no regeneration from Python signatures, so items/enum/pattern/bounds survive on every surface. tests/data/cloud_mcp_contract.json freezes the contract; a parity test guards drift.
  • Local is an honest subset: tools that don't exist locally (folders, search_documents, get_document_image) are not registered, mirroring the server's gating semantics. Cloud-only parameters (folder_id, sort/query, recursive) are hidden from the local surface entirely β€” strict-schema frameworks then cannot express the dead-end calls, and the call_tool/MCP path still answers direct calls with a guided "works on PageIndex cloud" envelope as the backstop. Local descriptions and instructions teach only that surface: the exposed schema is the contract minus the documented hidden set (mechanically asserted), and a dead-reference test keeps local guidance from naming cloud-only tools. remove_document is off by default, behind include_management=True.
  • The management gate is structural wherever possible. The config-handoff surfaces β€” as_claude_mcp(), as_openai_tools(hosted=True), the raw connector URL β€” point at the server's read-only endpoint (/mcp?tools=read) by default, so the URL itself is the gate and works identically in every MCP client. The in-process surfaces (agent_tools(), as_openai_tools(), as_anthropic_tools()) expose only tools the server marks readOnlyHint; local withholds remove_document at registration. include_management=True is the one switch that opens the complete list in either mode, on every surface. All four tool exports stay polymorphic: on a cloud client the live tool set β€” including new server-side tools β€” arrives without an SDK release.
  • Tools never raise, and failures carry the protocol's own error marking β€” every failure returns the same {"error", "errorCode", "next_steps"} envelope the cloud emits, flagged through each channel that has one (MCP isError propagated, Anthropic tool runner is_error: true via ToolError), so the model can always tell a failed call from data. Destructive calls validate every argument before acting β€” a rejection envelope means nothing was deleted.
  • agent_instructions(doc_id=None) supplies the retrieval playbook for the agent's system prompt. Cloud: the live instructions the MCP server serves for the key's tool set, captured from the initialize handshake over the same bridge session β€” server-side guidance updates arrive without an SDK release, and an empty server response raises instead of silently substituting. Local: the built-in playbook for the in-process tools, a trimmed subset with a consistency test that every tool it names exists locally. doc_id (str or list) appends the target documents β€” in the run above it is what let the agent skip discovery.
  • One new base dependency β€” openai-agents, the chat engine (chat() is the front door; ~15 MB on a base tree already carrying litellm + openai; the >=0.18.1 floor is the live-probed minimum that works with current openai, and the latest release passes the full suite). claude-agent-sdk / anthropic stay call-time imports behind vendor extras with actionable errors; the [openai] extra remains declared but empty so existing install commands keep resolving. The litellm floor rises to 1.97.0 β€” the release whose bridge routes sol-class chatcmpl+tools calls onto /v1/responses automatically (A/B-verified against 1.96.2), so the default chat model works out of the box. That release also ships a Python 3.10 defect β€” Message/Delta annotations whose nested forward refs don't resolve, killing every completion() (upstream [Bug]: pydantic.errors.PydanticUserError: Message is not fully definedΒ BerriAI/litellm#36384) β€” repaired by a version-gated, best-effort model rebuild at our completion gateways that no-ops on 3.11+ and on fixed releases.
  • submit_document(wait=True) polls with growing intervals; returns on completed, raises on failed or after 30 minutes β€” the manual polling loop cloud callers write today spins forever on a failed document.
  • Local indexing defaults to Flash with the full optimize pass (deterministic merge, then LLM expand; summaries and expand share summary_model). page_index_flash() takes optimize="full" (default) / "merge" / False; True is accepted as "full" for backward compatibility, unknown values raise instead of silently degrading. The CLI's --mode {flash,standard} replaces --flash (kept as a hidden compatibility alias); standard-only tuning flags now error in flash mode instead of being silently ignored; the missing-key pre-check (litellm.validate_environment, all providers) runs only when an LLM will actually be called. Both modes emit identical output schemas end to end.

Design β€” chat on the tools

  • Basis is industry standards, not the cloud chat endpoint. The cloud /chat/completions quirks (history flattening, bespoke prompt, stateless re-reading, arbitrary caps) are not mirrored; local targets the standard formats and becomes the reference the cloud can later converge toward. responses()/messages() raise on cloud clients until then.
  • Passthrough doctrine. Content is never rewritten β€” the caller's messages, the model's answers, tool outputs, native finish/stop reasons. The SDK owns exactly four things: gatekeeping (structural validation only β€” no message caps, sampling params pass through), table-setting (thin chat header + the local AGENT_INSTRUCTIONS; caller system content appended, not rejected; the doc_id targeting block leads the conversation and the tool layer enforces it), tool execution (read-only local set, scoped to doc_id), and billing (usage aggregation, envelope ids). Per-run tracing is disabled; prompt-cache routing keys are per-conversation, never pooled across users.
  • Engines are the vendors' own loops, never hand-rolled: openai-agents for the two OpenAI protocols, the Anthropic SDK's tool_runner for messages() (floor 0.108.0 β€” the first release whose runner stops at a refusal carrying a tool_use block instead of executing the tool; verified by probing mock transports against 0.84.0 through 0.108.0). The chat lane routes every model through LiteLLM β€” bare names are OpenAI-compatible shorthand (env config unchanged), and no prefix triggers a direct lane, so a routing decision can never hide in a model-name spelling; responses() stays on the OpenAI SDK natively (LiteLLM cannot speak the Responses wire format). Rule of the layer: engines = each vendor's official thin loop; agent hosts (Claude Code et al.) only ever get tools.
  • Envelopes report what actually happened. responses() carries the backend's real terminal status/incomplete_details (recorded at the transport layer β€” the engine discards them), a partial page read names every omitted page, and framework exceptions surface as PageIndexAPIError, never as raw engine types.
  • Prompt-cache continuity is a tested contract. Round-tripped history reaches the backend as an item-for-item extension of the previous call's final model input β€” asserted on both engines against captured payloads. Anthropic's explicit cache_control breakpoints sit on the managed system blocks only. The same decision reaches the LiteLLM lane: Claude models routed through LiteLLM β€” Anthropic direct, Bedrock, and Vertex, resolved by LiteLLM's own get_llm_provider β€” pass LiteLLM's cache_control_injection_points (via the Agents SDK's extra_args, both documented parameters), so the managed prefix (tools + instructions + doc block) caches there too. Each channel live-verified with a writeβ†’read cycle (anthropic per-turn reads through the full stack; Bedrock 7264 and Vertex 4842 tokens read on the second call).
  • chat() is output sugar, not a fourth protocol. It returns the answer string and hides the envelope; the wire underneath is chat_completions() unchanged, so it works on every backend in both modes. Its contract is the one surface not pinned to a wire format β€” a future engine= selector is a non-breaking add β€” while a merged multi-protocol method stays rejected: round-trip formats are protocol-specific, so a switch parameter abstracts nothing.
  • Model roles resolve in one seam. ConfigLoader.load() fills index/summary/chat from whichever names were given β€” new names win over old, specific over general, model sets every role, and the built-in defaults close each chain. The packaged config.yaml ships no model keys (comments only), so key presence is what distinguishes a user's choice from a default; every name that ever shipped in a release stays accepted, without deprecation warnings.
  • Tuning params are protocol-native and verbatim. Each door speaks its own protocol's vocabulary (reasoning_effort / reasoning / thinking; max_tokens / max_output_tokens), values forwarded untranslated with no defaults of ours β€” capability rules are the backend's (LiteLLM refuses unsupported combinations loudly, wrapped as PageIndexAPIError). The named sampling knobs ride ModelSettings fields β€” the one channel clean on every lane β€” while extra_body covers fields the methods don't name: OpenAI destinations get a true body merge, LiteLLM providers take the keys as LiteLLM's own params, messages() hands them to the Anthropic SDK's native extra_body. Merged last, so caller keys win.
  • Connection config is per-lane and verbatim. index_backend / chat_backend (and per-call backend) hand each stack its own connection params untranslated: the indexing gateways take the dict as LiteLLM call kwargs scoped by a contextvar (the env-key pre-check yields to it; the openai-SDK fast path normalizes api_base), the chat lane lifts api_key/base_url into LitellmModel's two pinned constructor slots and rides the rest as call kwargs, responses()/messages() construct their SDK clients from the dict (unknown keys raise, wrapped uniformly). Per-call wins over per-client; chat() takes no per-call backend but honors the client's; config bundles deliberately carry no credentials β€” they run in your environment.
  • enable_citations raises as cloud-only (citations need block-level OCR data local mode does not store).
  • messages() resolves its max_tokens default per model (8192, or 4096 for the claude-3 generation), so the simple call needs only a question on any model.

Verification

  • 295 tests green (plus 3 skipped without the claude extra and 3 key-gated live tests): tool behavior against a seeded store with no LLM calls; the real chat engines against scripted backends (a Model fake under openai-agents, a mock HTTP transport under the real anthropic SDK); contract parity vs the frozen snapshot; framework-missing/-installed behavior both ways; streaming on every chat surface; the chat() front door (answer extraction, streamed chunks, multi-turn history passthrough, cloud envelope unwrap); the round-trip prefix-extension assertions on both engines; doc_id scoping, error-marking, and envelope-honesty regressions; a model-resolution matrix with one row per released knob generation; per-door passthrough delivery (reasoning channels, sampling knobs on ModelSettings, extra_body asserted on the captured anthropic wire body); backend delivery per lane (contextvar scoping on both gateway paths, the env-precheck yield, constructor lift and kwargs remainder on the LiteLLM lane, SDK-client construction on responses/messages, merge precedence) and extra_headers on every door (anthropic-beta asserted on the anthropic wire).
  • Live against the real cloud MCP server: agent_tools() and as_anthropic_tools() discovered this key's gated tool set (7 read-only tools; include_management=True adds remove_document); frozen-contract parity letter-for-letter; envelope field parity on the analogous calls; the server serves non-empty initialize.instructions.
  • Live against real model backends: OpenAI β€” chat_completions answered with the structure-first loop; responses round-trip answered the follow-up with zero new tool calls. Anthropic β€” messages() history was accepted verbatim by the real API, follow-up answered with zero new tool turns, cache_control hit live (cache_read_input_tokens: 1826), native streaming; both the sync and AsyncAnthropic tool runners drove the live cloud tools end-to-end; the Messages API MCP connector reached api.pageindex.ai/mcp server-side (mcp_tool_use/mcp_tool_result in a single call).
  • Review record: multiple independent multi-agent review rounds ran over each layer (adversarial runtime probes, claims-vs-code, refactor-equivalence audits, best-practice review against the frameworks' source); every finding was reproduced before being fixed, and deliberate non-changes are documented alongside. The tools layer's rounds are recorded in feat: agent tools β€” OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloudΒ #393; the chat and tools-export rounds in this PR's commit messages. A maximum-effort whole-PR review round then ran over this diff β€” 26 verified findings, 20 fixed in the first pass (d87fa89, b135711, eb1a230), the remainder triaged with rationale. Nine further review rounds followed (commit messages 31c9150 through eebed64), covering argument coercion, protocol terminal states, envelope honesty, provider error containment, CodeQL findings, and SDK dependency floors β€” each finding reproduced before the fix landed. The feat: model knobs, LiteLLM-verbatim chat lane, per-door passthrough paramsΒ #409 wave (model knobs, routing flip, passthrough params) went through three more audits with wire-level probes: LiteLLM's max_tokens translation verified on both of its paths (chatcmpl max_completion_tokens, bridge max_output_tokens), extra_body delivery captured per lane, and the sol-bridge litellm floor bisected to the exact release by source and behavioral A/B. The backend wave (feat: backend connection overrides; extra_headers on every local doorΒ #411) ran three further audits backed by ~20 mock-server wire probes that mapped LiteLLM's channel semantics per adapter (kwargs vs extra_body vs headers), re-proved custom-header forwarding after catching a case-sensitivity bug in the probe itself, and isolated the anthropic-beta drop to LiteLLM's completion() plumbing β€” its transformation layer merges user betas when called directly.
  • Post-feat: agent tools and local chat for the PageIndex SDK (v0.2.10)Β #396 fixes carried here (merged to main via fix: post-merge review fixes for v0.2.10Β #402/feat: Flash with full optimization becomes the default local indexing modeΒ #404): conformant responses() envelope β€” official output (model items only) + items (full transcript for round-trip) + usage aggregated across turns, verified against the real OpenAI API; python floor declared >=3.10; stale anthropic>=0.84.0 hints updated to the real 0.108.0 floor; the bridge's binary-stub behavior disclosed on the two image-advertising tool surfaces; _run_sync moved off the except RuntimeError probe so user exceptions stop carrying a phantom "no running event loop" context.

Release gate β€” satisfied: the default cloud configs point at the read-only MCP endpoint, so VectifyAI/pageindex-chat#448 had to be deployed before 0.2.10 ships (an older server ignores the tools=read parameter and would silently serve the full set behind a URL that promises read-only). Verified live before publishing 0.2.10.dev1: …/mcp?tools=read serves 7 tools without remove_document, …/mcp serves 8 with it.

Follow-ups (not in this PR): an AsyncPageIndexClient twin per the industry dual-client pattern β€” every layer around the SDK is already async-native (FastAPI server, agent engines, agent frameworks); the async chat path is the engines' native form (drops the sync bridge, streams pass through as async for), and cloud transport gains an httpx track; a stdio pageindex-mcp entry point for non-Python MCP hosts; a public doc_id scope on the BYO tool exports (the chat surfaces already enforce it); the docs-site agent-integration page; cloud /responsesΒ·/messages convergence toward these surfaces.

rejojer added 30 commits August 11, 2026 22:00
Four new client methods make PageIndex documents available to agent
frameworks, in both modes, with the mode decided solely by the client
constructor:

- agent_tools(): plain functions (browse_documents, get_document,
  get_document_structure, get_page_content) matching the PageIndex cloud
  MCP server's tools/list β€” same names, schemas, descriptions, and JSON
  response envelopes β€” so agent prompts port unchanged between the cloud
  MCP connection and these in-process tools. Tools never raise; errors
  come back in the same envelope. remove_document ships behind
  include_management=False.
- as_openai_tools(): the same tools wrapped for the OpenAI Agents SDK.
- as_claude_mcp(): one mcp_servers entry for the Claude Agent SDK β€”
  cloud clients get the remote MCP config (the framework connects to
  api.pageindex.ai/mcp and discovers the full cloud tool set), local
  clients get an in-process SDK MCP server.
- agent_instructions(doc_id=None): orchestration guidance for the
  agent's system prompt; doc_id (same shape as chat_completions) appends
  the target documents.

submit_document() gains wait=True: poll get_document status until
completed, raise on failed or after 30 minutes β€” the manual polling loop
every cloud caller writes today spins forever on a failed document.

Neither framework becomes a dependency: imports happen at call time with
actionable errors, and pageindex[openai] / pageindex[claude] extras are
floor-only pins. tests/data/cloud_mcp_contract.json freezes the tool
contract; a parity test guards against drift. 36 new tests (95 total),
plus a live OpenAI Agents SDK run over a seeded local store verifying
the structure-first navigation flow end to end.
…mantics

- Large-doc next_steps now says structure-first, consistent with tool
  descriptions and agent instructions
- _remove_document fetches document list once instead of per-name
- call_tool returns error envelope for unknown names instead of raising
- _not_ready_error timed_out flag reflects actual wait outcome
- openai_agents.py docstring corrected to match default (FunctionTools)
- Removed unused ModelSettings import from demo
…data merge

- McpBridge reads session/protocol headers under the lock (now RLock:
  _ensure_initialized posts while holding it). openai-agents runs sync
  tools on threads and executes parallel tool calls concurrently, so
  bridge functions genuinely race; a torn read sent a new session id
  with a stale protocol header. Measured: one session expiry under 8
  threads cost 4 initializations before, minimal 2 after.
- Session-expiry retry also resets the negotiated protocol version, so
  the re-handshake carries no stale MCP-Protocol-Version header.
- browse_documents time sort pages list_documents natively instead of
  fetching the whole library to slice one window (relevance still needs
  the full list for scoring).
- _await_completion: a status refetch that nulls out metadata no longer
  clobbers the listing's copy (setdefault was a no-op on existing None).
- Structure tool reads the raw stored tree via a named LocalAPI
  raw_tree() seam instead of reaching into _api._store internals; drop
  the redundant deepcopy before _format_structure (store re-reads from
  disk, formatting builds fresh containers).
- Shared pageindex/_version.py replaces _sdk_version duplicated in
  mcp_bridge and the Claude integration.

Left as-is after source verification against the cloud MCP: first-page
budget bypass, pageNum falsy-zero, and the page-gap fallback text are
letter-for-letter cloud behavior β€” parity wins over local repair.
…lience, contract drift

- _parse_page_spec bounds the requested span arithmetically (10k pages)
  before materializing it; pages="1-1000000000" previously expanded to a
  billion integers inside the caller's process.
- Local submit_document uniquifies document names the way the cloud
  upload does (taken name -> _1.._99, then reject with the cloud's own
  message). Same-name duplicates broke name-addressed tools: resolution
  always picks the newest, so older duplicates were unreachable.
- agent_instructions(doc_id=...) now fails loud when the pinned doc's
  name is shadowed by a newer same-name document (legacy stores predate
  the rename) β€” it previews resolution with the same _resolve_document
  the tools use, so the check cannot drift from actual behavior.
- submit_document(wait=True) tolerates transient network errors, not
  just API errors; a dropped connection at minute 25 of a 30-minute
  wait no longer kills it. Third strike wraps into PageIndexAPIError
  per the documented contract.
- The live contract-parity test compares full per-param schemas, not
  just names and descriptions. It immediately caught real drift the
  shallow check had been passing: the server now emits nullables as
  anyOf unions and stamps MAX_SAFE_INTEGER maxima on offset/part.
  Contract and snapshot updated to the served wire form; _annotation_for
  learned anyOf so bridge signatures stay Optional[str] instead of
  degrading to Any.

Adjudicated, not changed: the allowed_tools wildcard example stays
(docstring advice covers scoping; Ray's call), and raw-length response
accounting stays (letter-for-letter cloud behavior, parity wins).
Compute PR #558 makes /doc/ return {"doc_id", "name"} carrying the
post-dedup-rename name. Mirror it end to end: local submit returns the
stored name, the client warns when it differs from the uploaded file
name (read via .get so older cloud servers stay compatible), the local
name-exhaustion check runs before indexing instead of after the LLM
spend, and the demo caches doc_id in a file instead of name-matching β€”
a renamed document made the name lookup re-index on every run.
The cloud MCP server publishes its agent instructions in the initialize
result, adapted to each key's tool set. agent_instructions() previously
returned the SDK's local-subset text in both modes β€” a silently forked
copy that lacks the guidance for cloud-only tools (search_documents
escalation, folders, images) and drifts as the server's prompt evolves.

Cloud clients now serve the server's live instructions, captured from
the initialize handshake on a per-client bridge shared with
agent_tools() (one session, no extra request). An empty server response
raises instead of silently substituting the subset text β€” same posture
as the annotation-regression guard. The local constant stays as the
honest subset for the in-process tools, with its provenance noted and a
consistency test that every tool it names exists in the local registry.
sort="relevance" is cloud-side semantic ranking; the local substring
imitation could satisfy the letter of the interface while silently
missing semantically relevant documents. Per the honest-subset rule
(same treatment as folders), local now returns the "not available
here" envelope for sort="relevance" or a stray query, and the local
instructions steer discovery through name/description matching plus
full-library paging instead of prescribing a capability that does not
exist here. The tool schema keeps the cloud contract verbatim, like
folder_id: honesty lives in the runtime answer, not a forked contract.
"Not available here" read as a broken feature; the honest framing is
that folders and semantic ranking exist on PageIndex cloud and are not
in local mode yet. Both envelopes now say so and name the cloud client
in next_steps, so agents relay an accurate story to the user.
The cloud-verbatim browse_documents description invites
sort="relevance" and folder drilling, so a local agent's first semantic
search attempt was a guaranteed dead end discovered only from the
runtime error envelope. Local registration now appends a LOCAL MODE
note to the description β€” the agent learns what is cloud-only before
calling; the runtime envelope stays as the backstop for prompts that
ignore descriptions. The cloud-facing contract stays byte-verbatim.
Appending a retraction to the cloud-verbatim description left the model
parsing an instruction and its negation β€” and kept the cloud text
recommending search_documents and get_folder_structure, tools that are
not registered locally (get_page_content likewise pointed at
get_document_image). Guidance now adapts to the local surface the way
AGENT_INSTRUCTIONS already does: schema structure stays byte-identical
to the contract (mechanically asserted by a strip-descriptions test),
while local description strings teach only what works here and point to
PageIndex cloud for the rest. A dead-reference test forbids local
guidance from naming tools outside the local registry, so a contract
refresh that reintroduces a cloud-only reference fails loudly.
folder_id, sort, query, and recursive were exposed locally with
localized "cloud-only" descriptions, leaving the dead-end calls
expressible and discovered at runtime. Schema constraints beat
guidance: the local surface now serves the contract minus these
parameters, so strict-schema frameworks make the calls inexpressible
and a prompt that insists on sort="relevance" degrades to the bare
call (the correct local behavior) instead of an error round-trip.

The implementations still accept the hidden parameters and answer with
the guided "works on PageIndex cloud" envelope β€” the backstop for
direct call_tool callers and hosts without schema enforcement.
wait_for_completion stays: seeded or torn stores can hold documents
that are genuinely not completed. The structural guard now asserts the
local schema equals the contract minus the documented hidden set,
descriptions aside.
Three independent review passes over the agent-instructions increment
surfaced six fixes:

- The per-client bridge moved off the instance into a weak-keyed,
  lock-guarded module cache: cloud clients stay picklable
  (threading.RLock no longer rides on the client) and concurrent first
  calls can no longer construct duplicate bridges/sessions.
- Blank or non-string initialize.instructions now hit the same honest
  error as a missing one β€” a whitespace-only or structured value could
  previously become the system prompt (or crash the doc_id append with
  a raw TypeError).
- The invalid-sort envelope no longer prescribes sort="relevance" β€” the
  one error text that still taught the cloud-only value it would then
  reject.
- "Page through the rest of the library" is emitted only when has_more
  is true; a fully-listed library no longer instructs a pointless call.
- The mandatory full-library paging step now says limit: 50 β€” 6 calls
  instead of 30 on a 300-document library.
- Docstrings and comments rescoped to what is actually true: the
  never-raise contract covers invocations the signatures accept
  (unknown params fail at the Python boundary; call_tool answers them
  with the guided envelope), recursive is accepted as the identity
  rather than errored, lenient framework arg models drop hidden params
  pre-call, and the module header no longer claims full schema parity.
  The capability-phrase guard now covers every local docstring, not
  just browse_documents.
The frozen contract guards tools/list, but the response envelopes the
local tools emit were hand-built to mirror the cloud's and had no drift
detector. A key-gated live test now asserts every field local emits
exists in the live cloud response for the analogous call (top-level
keys, next_steps, document entries, structure nodes, content entries).
Guidance wording is deliberately localized and not compared. Verified
green against the live server: local and cloud field structures
currently match exactly.
….10)

Local mode gains managed document QA: an agent over the #393 local tool
set, reachable through three wire protocols, each 1:1 with the backend
and with no translation layer.

- chat_completions(): standard chat.completions semantics on any
  OpenAI-compatible backend (openai-agents engine). Final answer only,
  cross-turn aggregated usage, streaming as text pieces or chunk dicts
  (the existing cloud signature, now implemented locally; model and
  max_turns are local-only additions).
- responses(): the agentic surface β€” OpenAI Responses format, the tool
  process is standard output items, streaming forwards native events
  (tool outputs emitted as response.output_item.done, the way the
  platform streams its own server-side tools). Round-tripping output
  into the next input keeps provider prompt-cache prefix continuity and
  the agent's memory β€” live-verified: the follow-up call answered from
  round-tripped tool output with zero new tool calls.
- messages(): Anthropic-native via the SDK's own tool runner (new
  pageindex[anthropic] extra, floor 0.68.0 verified for
  tool_runner/beta_tool(input_schema)). tool_use/tool_result round-trip
  is the format's native behavior; the envelope is the final message
  with aggregated usage plus the full new-turn sequence; the managed
  system blocks carry cache_control breakpoints.

Shared skeleton: thin chat header + the local AGENT_INSTRUCTIONS
(caller system content is appended, not rejected), the doc_id targeting
block as a leading context item (factored out of
build_agent_instructions), read-only toolset, structural-only
validation (no arbitrary caps β€” backend limits govern), sampling params
passed through, per-run tracing disabled, enable_citations rejected as
cloud-only. Design basis is industry-standard formats rather than the
cloud chat endpoint; responses()/messages() raise on cloud clients
until the cloud converges.

Tests run the real engines against scripted backends (a Model fake for
openai-agents, a mock HTTP transport under the real anthropic SDK) with
real tool execution against a seeded store, including the round-trip
prefix-extension assertions on both engines.
Three independent review passes (bug scan, claims-vs-code, adversarial
runtime probes) over the local-chat increment; every fix below was
reproduced before being fixed.

messages():
- A max_turns cut no longer duplicates the final assistant turn: the
  runner has already appended it when iterations exhaust, so the
  round-trip history carried a duplicate tool_use id and ended on an
  unanswered tool_use β€” a guaranteed 400 on continuation. The append
  now keys on stop_reason, and truncation reads natively as
  stop_reason: "tool_use" with a continuable history.
- The envelope is JSON-serializable end to end: runner-stored turns
  carry pydantic content blocks; everything is dumped to plain dicts,
  excluding SDK-internal __api_exclude__ fields (parsed_output) that
  the API rejects on round-trip.
- Bounded by default (max_iterations 10, like the OpenAI surfaces);
  usage aggregation now preserves the final turn's native fields and
  sums the token counters None-safely; empty caller system strings are
  skipped; non-dict message entries and bad doc_id types raise
  PageIndexAPIError; anthropic < 0.68 gets an actionable version error;
  the doc block no longer spends a cache_control breakpoint.

chat_completions()/responses():
- MaxTurnsExceeded wraps into PageIndexAPIError on all four run paths.
- responses(stream=True) is one logical response: per-turn backend
  lifecycle events are collapsed (a canonical consumer previously
  stopped at turn 1's response.completed and never saw the answer),
  sequence numbers are reassigned monotonically, and the synthesized
  tool-output event carries output_index/sequence_number.
- The responses envelope carries the real request surface
  (instructions, the actual function tool definitions, tool_choice,
  parallel_tool_calls, error/incomplete_details).
- RunConfig(group_id) pins a stable prompt_cache_key: openai-agents
  otherwise stamps each run with a fresh key, tagging round-tripped
  prefixes as different cache groups and defeating the feature the
  round-trip exists for.
- Abandoning a stream now cancels the run: a watchdog task lets the
  cancellation land even while the pump awaits the backend, and the
  per-call AsyncOpenAI client is closed before its loop ends (fixes
  "Task exception was never retrieved" noise). The opening role chunk
  is emitted even for empty outputs; empty responses() input and
  enable_citations-before-extra ordering fixed.

Docs rescoped to what is true: finish_reason/status reflect loop
completion on the OpenAI surfaces (the engine does not surface per-turn
backend reasons); chat streaming yields visible narration including
pre-tool text; messages(stream=True) forwards the Anthropic SDK's
native event objects (not wire-verbatim); the doc block is a leading
conversation item on OpenAI surfaces and a system block on messages().

Tests: 25 in the file (11 new), with per-extra skip sections so a
machine with only one framework still covers the other surface;
without-frameworks matrix re-verified; live smoke re-run green with a
clean exit.
Fills the last cell of the agent-connection matrix: users driving their
own anthropic tool_runner loop get runnable tools directly. Cloud wraps
the live MCP tool set with input schemas passing through verbatim (MCP
inputSchema is the Messages API schema shape); local exposes the same
set messages() runs internally. The beta_tool wrapping moves from
local_chat into integrations/anthropic_sdk.py, parallel to
openai_agents.py, and messages() now consumes the shared builder.
agent_tools grows _bridge_invoker/_read_only_tools so the plain-function
and beta_tool cloud paths share invocation containment and the
read-only gate.
Adversarial + best-practice review of 4590dd8 (three independent passes)
surfaced two holes. The export was sync-only: AsyncAnthropic's runner
accepts only BetaAsyncFunctionTool and splices anything else into the
request body unserialized, so the first call died with an opaque
TypeError β€” asynchronous=True now builds beta_async_tool runnables
(present since the 0.68.0 floor) that run the blocking bridge/store call
in a worker thread, keeping I/O off the caller's event loop. And
beta_tool stores input_schema by reference, so cloud tools aliased the
bridge's cached metas while the local path deep-copied β€” the builder now
copies, and the passthrough test asserts equal-but-not-aliased so it can
no longer compare an object with itself. Docstring fixes from the same
round: the MCP-connector pointer now carries the full live-verified
shape (authorization_token was missing β€” following it literally gave a
401), and the manual messages.create loop's to_dict() serialization is
documented. Tests pin the runnable flavor both ways (isinstance), which
existing tests could not distinguish.
…onversation

The targeting block doc_id adds is re-set on every call and sits in the
cached prompt prefix, so a round-trip that drops (or changes) doc_id
silently diverges the prefix and loses the cache continuation. State the
rule on all three chat surfaces' doc_id docs, and pin it with a prefix
test that passes the same doc_id on both calls.
query + doc_id is the minimal PageIndex contract, so it now works
uniformly: chat_completions and messages accept a plain string (one
user message), as responses always did per its wire format. The wrap
is input sugar at the SDK surface, not a translation layer β€” the
outgoing wire is unchanged, and managed agent surfaces taking strings
is the ecosystem convention (Runner.run, claude_agent_sdk.query).
Cloud chat_completions gains the same acceptance; blank strings raise
on every path.
The Messages API requires a per-turn output budget on the wire, but
that is table-setting, not a PageIndex-layer user obligation β€” the
simple call is now a question + model + doc_id. The knob stays
overridable (passthrough intact); model stays required because no
cross-vendor default is honest to guess.
max_tokens is a cap, not consumption, so the default should be the
highest universally safe value: 4096 could truncate long-form answers
(whole-document summaries), while 8192 is the output ceiling every
non-EOL Claude model accepts and stays under the SDK's non-streaming
long-request threshold.
Inserting tests above decorated ones absorbed their @needs_agents
markers, so two tests ran (and failed) in the without-frameworks CI
job. Both simulated-bare and full runs are green again.
Tool layer:
- anthropic adapter: failed tool calls raise ToolError so the runner
  emits tool_result is_error:true; McpBridge.call_tool returns
  (text, is_error) and surfaces the server's MCP isError marking
- as_openai_tools builds FunctionTool with the contract/server schema
  verbatim (strict off) β€” function_tool() regenerated schemas from
  signatures, dropping items/enum/pattern/bounds and aborting the whole
  list on object-typed params; shared _tool_specs() feeds both adapters
- remove_document validates every name before deleting anything; call_tool
  classifies only bind-time TypeErrors as INVALID_INPUT
- unknown-tool envelope formatted with _dumps like every other envelope

Local chat:
- doc_id is enforced at the tool layer (allowlist threaded through
  call_tool and the adapters), not just prompted; the shadow check runs
  inside the scope
- _openai_model routes litellm/ and provider/ paths via LitellmModel and
  strips openai/ β€” the normalized retrieve_model 404'd as a raw wire name
- responses() reports the backend's real terminal status (recorded at the
  transport client; the framework discards Response.status) and wraps
  framework exceptions in PageIndexAPIError
- chat_completions streaming yields its opening chunk inside try, so an
  abandoned iterator still cancels the run and closes the backend
- prompt-cache group_id is per-conversation (model+instructions+first
  item) instead of one global constant pooling every user
- messages() max_tokens default resolves per model (claude-3 caps at 4096)

Packaging / surface:
- __init__ registers the 0.2.10 modules in _SUBMODULES; unknown names
  raise AttributeError instead of eagerly importing page_index_classic
- anthropic floor 0.84.0: first release with ToolError whose runner also
  executes the final turn's tools on a max_iterations cut
- client docstrings caught up with local chat landing

Claude Agent SDK gate:
- claude_allowed_tools(mcp_servers) derives mcp__<key>__<tool> entries
  from the caller's own registration map (live server annotations on
  cloud, the contract locally) β€” no name is ever spelled twice
- claude_agent_config() bundles the three slots as one-call sugar over
  the explicit form

Examples:
- demo runs against cloud again (getattr for local-only attrs) and finds
  an existing indexed copy by name before re-indexing

Tests: monkeypatches replace the consuming module's binding instead of
mutating the shared time/requests modules; 185 -> 211.
claude_agent_config() gets two symmetric siblings, so each framework's
front door is a single splat over the same explicit primitives:

- openai_agent_config(): Agent(**...) kwargs β€” instructions, tools, and
  the local retrieve_model (cloud omits model for the framework default)
- anthropic_runner_config(): tool_runner(**...) kwargs β€” system, tools,
  and the messages() defaults (per-model max_tokens, 10-iteration bound);
  only the user's messages remain

Bundles stay pure sugar: doc_id rides agent_instructions, no extra
semantics over the explicit form, docstrings point both ways. The demo
agent shrinks to Agent(**client.openai_agent_config(doc_id=...)).

Construction is pinned against the real frameworks in tests (Agent and
tool_runner both built offline), so an upstream kwargs rename fails
loudly; 211 -> 215 tests.
…lation, output_index axis

- get_page_content: the summary is additive, not either/or β€” a call that
  both truncates for size and has out-of-range pages reported only the
  latter, telling the agent every in-range page was returned (#2)
- McpBridge._extract_result: strict request-id correlation only; the
  eager fallback could hand back a stale or mis-correlated JSON-RPC
  message as this call's reply (#16)
- responses() streaming: output_index now addresses the logical
  response.output β€” backend per-turn indexes are re-based past prior
  turns' items and the SDK-injected tool outputs take the next slot on
  that axis, instead of reusing the event-sequence counter (#15)

215 -> 217 tests.
pageindex-chat#448 adds /mcp?tools=read β€” the server registers only
readOnlyHint-annotated tools β€” so the URL itself becomes the gate for
every surface that hands a config to a third party:

- as_claude_mcp: include_management now picks the endpoint on cloud;
  the parameter is real in both modes
- as_openai_tools(hosted=True): OpenAI connects to the read-only
  endpoint by default and require_approval simplifies to "never" β€” the
  approval-flow middle ground becomes hard absence, matching every
  other surface's default
- claude_allowed_tools() retired before ever shipping: with the server
  gated, allowed_tools degenerates to whole-server pre-approval, which
  claude_agent_config emits as the constant ["mcp__<name>"] β€” no
  setup-time bridge round-trip remains
- in-process surfaces (agent_tools, as_openai_tools, as_anthropic_tools
  over the bridge) keep bare /mcp + client-side annotation filtering:
  they materialize tools locally and hand no URL to anyone

Release ordering: 0.2.10 must ship after pageindex-chat#448 deploys β€”
an older server ignores unknown query params and would silently serve
the full set behind a URL that promises read-only.
…LLM's, verbatim

Ray's ruling on the flip's remaining carve-out: a routing decision must
never hide in a model-name prefix. openai/ now means what LiteLLM says
it means (its openai provider), like every other name on the chat lane β€”
the grammar is LiteLLM's with zero exceptions.

The two defenses for keeping a direct carve-out had no concrete victim:
debugging isolation (litellm is unavoidable in indexing anyway, and
responses() IS the OpenAI-SDK-native door), and endpoint determinism
(litellm sends chatcmpl for openai-provider models except the gpt-5
bridge, which never fires against a custom base_url β€” wire-verified).
If a direct escape is ever needed, it will be a declared parameter,
never name grammar.

openai/-prefixed names keep the build-time OPENAI_API_KEY check for
parity with bare names; responses() is untouched (bare and openai/
still drive the OpenAI SDK β€” LiteLLM cannot speak that protocol).
The prompt_cache_key delivery channel went through three iterations and
settled on ModelSettings.extra_body; the _conversation_cache_key
docstring still named extra_args from the middle iteration.

The litellm install hint said >=1.30, below both our own pyproject floor
(>=1.84.0) and the floor openai-agents' litellm extra declares (>=1.83).
A user in a broken environment following it would land on a version the
package itself rules out. The hint now matches the declared floor.
@rejojer
rejojer force-pushed the feat/local-chat branch 2 times, most recently from 7e0ba77 to 863ff09 Compare August 16, 2026 13:07
…e-key test to extra_body

The config bundle hands its model string to the Agents SDK's own
MultiProvider grammar, which refuses unknown prefixes (probe on 0.20:
'anthropic/x' -> UserError: Unknown prefix). _normalize_retrieve_model's
litellm/ spelling is what keeps that door working β€” a link the existing
self-referential assert (config["model"] == client.retrieve_model)
could not catch. Pinned with a provider-slashed name.

Also renames the cache-key delivery test to its real channel,
extra_body β€” the extra_args name survived from the superseded delivery
attempt.
rejojer added 11 commits August 16, 2026 21:12
…SDK's grammar

_normalize_retrieve_model said what it does, not why. The litellm/
spelling exists because the Agents SDK resolves raw model strings with
its own prefix grammar and refuses unknown prefixes β€” the name now
points at that constraint.
…ile's siblings

Every agents-dependent test in this file importorskips; without the
guard this one errors where the others skip.
…acy fallback

The documented surface becomes two role knobs: index_model builds the
index, chat_model answers on the chat surfaces. model turns into the
set-both umbrella (its 0.2.8 indexing semantics are a strict subset, so
old configs run unchanged); summary_model and retrieve_model stay
accepted as legacy role names.

Resolution lives in ConfigLoader.load(), the one seam every consumer
already passes through (client, CLI standard/md paths, flash's
summary fallback, tree_optimize's default_model): new names win over
old, specific over general, model sets every role, and code constants
close each chain. The packaged yaml no longer ships model keys β€” key
presence is what separates a user's explicit choice from a built-in
default, and _validate_keys accepts the five model names explicitly.

Consequences: with no config at all, classic-mode structure extraction
now uses DEFAULT_INDEX_MODEL (gpt-5.6-luna) instead of the yaml's old
gpt-4o-2024-11-20 line (ratified; flash-default users see no change).
client.retrieve_model becomes a read-only alias for client.chat_model.

The resolution matrix test pins one row per released generation:
0.2.8 (model), 0.3.0.dev (model+retrieve_model), 0.2.10.dev (all three
legacy names), the new pair, umbrella-only, and mixed.
The CLI leads with --index-model; --model stays as its legacy synonym
(the CLI only indexes, so the umbrella and the index role coincide).
The flash branch's summary fallback gains the index position, and the
standard branch now forwards --summary-model, which it had silently
ignored β€” the flag's help always claimed it worked there. The md
branch's unfiltered model=None no longer clobbers the default: the
resolver treats None as unset.
Ray's pick for the out-of-box QA default; indexing stays on luna. sol
runs tools on the Responses lane β€” current litellm bridges chat()
there automatically; older litellm gets the guided 400 naming both
exits.
The per-generation history lives in 7244ee4's message.
… thinking

Each chat door gains its own protocol's native thinking control,
forwarded verbatim with no invented vocabulary and no default of ours:
chat_completions(reasoning_effort=...), responses(reasoning={...}),
messages(thinking={...}). Unset sends nothing, so backend defaults
(sol: medium, adaptive) are untouched. chat() stays answer-only.

Delivery channels, each verified: the chat door rides
extra_args["reasoning_effort"] β€” LiteLLM's own top-level kwarg on
every supported openai-agents version, admitting non-enum values
("none"); newer openai-agents promotes it to the top-level argument
and pops the duplicate. Wire-captured on a mock backend
(/v1/chat/completions body carries it) and coexists with the Claude
cache marker in one dict. The responses door rides
ModelSettings.reasoning β€” coerced to the typed openai Reasoning object
and forwarded verbatim by the Responses model; the envelope echoes the
caller's dict. The messages door joins the existing anthropic
passthrough dict, asserted through the real tool runner.

LiteLLM semantics observed and accepted as-is: unknown models refuse
the param loudly with LiteLLM's own remedies, and gpt-5.4+ names with
an explicit effort route to /v1/responses even against a custom
api_base (its documented pre-existing arm). The sol-class 400 guidance
now names the third exit β€” an explicit effort routes on older litellm
releases too.

Cloud chat_completions rejects the new parameter like model/max_turns;
responses()/messages() are local-only already.
The industry-standard per-request extension channel (openai/anthropic
SDK trio): a dict merged verbatim into the backend request, last, so
caller keys win over SDK-set ones. Routing per door: OpenAI-compatible
destinations get a true body merge (ModelSettings.extra_body / the
anthropic SDK's native extra_body); LiteLLM-routed providers take the
keys as LiteLLM's own top-level kwargs instead, since LiteLLM plants
extra_body as literal fields other providers reject. Cloud mode rejects
it like the other local-only knobs. chat() stays answer-only.
1.97.0 is where the unset-effort chatcmpl->responses bridge landed
(responses_api_bridge_check's on_constraint_enforcing_endpoint arm,
A/B-verified against 1.96.2), so sol-class models work through the
chat lane out of the box instead of 400ing until a manual upgrade.
Three spots move together: the pyproject floor, the requirements.txt
CI pin, and the install hint.
…esponses

extra_body could not carry these: openai-agents' LitellmModel passes
every ModelSettings sampling field as an explicit keyword and unpacks
extra_args into the same call, so the common knobs collided with a bare
TypeError on the LiteLLM lane (reproduced against a stub β€” Python call
semantics, callee-independent). Named params ride ModelSettings fields,
the one channel clean on every lane; responses() uses the protocol's
own name (openai_responses maps ModelSettings.max_tokens to
max_output_tokens on the wire) and the envelope now echoes the real
value instead of a constant None. Both caps bound each backend call in
the agent loop, not the whole run β€” documented. Cloud rejects them like
the other local-only knobs; the long tail (frequency_penalty etc.)
stays extra_body-blocked-loudly on that lane by choice.
The default chat_model is what put keyless users on the OpenAI lane,
so the error now points at the knob that picks a different backend.
litellm 1.97.0 ships Message and Delta annotations whose nested forward
refs (ChatCompletionReasoningSummaryTextBlock et al) do not resolve on
3.10, so every completion() dies constructing its response object β€”
non-stream and stream alike (upstream BerriAI/litellm#36384, open, no
patch release; 1.96.2 is clean, so the floor raise surfaced it, and
pydantic 2.12/2.13 both reproduce). The repair rebuilds the two models
once with their defining modules' namespaces at our three completion
gateways; version-gated to <3.11 and best-effort, so it is a no-op on
healthy interpreters and future fixed litellm releases. Verified on a
3.10 venv: the previously failing anthropic wire test and the full
suite pass (250 green, matching CI's matrix leg).
Ruled in as a business-level control alongside model: who answers, and
how hard it thinks. Same name, values, and verbatim semantics as
chat_completions underneath (LiteLLM's cross-provider tier string);
unset sends nothing so each backend's own default behavior applies.
Sampling and wire-level knobs deliberately stay off the front door.
Two clients, two configs β€” the gap this closes. index_backend /
chat_backend on the constructor (and per-call backend on the chat
doors, mirroring per-call model) carry connection params in each
lane's own vocabulary, verbatim: the indexing gateways take the dict
as LiteLLM call kwargs (a contextvar scopes it per operation, and the
env-var key pre-check yields to it), the chat lane lifts api_key /
base_url into LitellmModel's two pinned constructor slots and rides
the rest as call kwargs, responses() and messages() hand the dict to
their SDK client constructors. Per-call keys win over the client's;
the openai-SDK fast path normalizes LiteLLM's api_base spelling.
Config bundles deliberately don't carry it β€” you run those in your
own environment (docstring says so).

extra_headers lands on all three protocol doors, each engine merging
caller headers verbatim (anthropic-beta wire-proven on messages).
Wire-probed exception, documented on the chat door: LiteLLM's
anthropic adapter owns the anthropic-beta header and drops the
caller's value β€” Anthropic beta flags belong on messages(). Cloud
rejects the new knobs like the other local-only params, and the
docstrings now say credentials belong in backend, never extra_body
(on the bare lane they would leak into the JSON body without
touching auth β€” wire-probed).
The class docstring documents every constructor argument; the backend
wave added two without entries. Same phrasing as the per-call docs:
index lane is LiteLLM vocabulary verbatim, chat_backend reaches
whichever door runs (api_key/base_url portable across all three).
The classic pipeline's ThreadPoolExecutor import (unused since the
0.2.9 merge) goes. The bare-model missing-key error now also names
the backend={'api_key': ...} route, which satisfies the same check.
messages() joins the uniform: a bad backend dict wraps as
PageIndexAPIError like the responses door, instead of leaking the
SDK's raw constructor error.
…backend

Anthropic's constructor raises only TypeError in our supported range
(unknown kwargs, conflicting credentials) β€” a missing key defers to
request time, so catching AnthropicError there guarded an impossible
case. And chat() has no backend parameter, so the missing-key advice
now names chat_backend alongside the per-call route.
@rejojer

rejojer commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. The missing-API-key pre-check never fires on the default CLI invocation (bug due to run_pageindex.py:93-95 β€” summary_model = args.summary_model or args.index_model or args.model is None unless a model flag is typed, so if summary_model and (...) short-circuits)

python3 run_pageindex.py --pdf_path doc.pdf β€” the command the README documents β€” defaults to flash + optimize=full + summaries, resolves gpt-5.6-luna from ConfigLoader, and does call an LLM, but skips the guard. The identical run spelled --index-model gpt-5.6-luna exits immediately with Missing API key for gpt-5.6-luna: OPENAI_API_KEY: same effective model, opposite behavior, decided only by whether a flag was typed. Keyless, the bare command instead burns full extraction plus the whole optimize pass before dying with a generic RuntimeError: Summary generation failed for all nodes; and when any leaf falls under SUMMARY_RAW_TEXT_TOKENS = 200 it needs no model call, satisfying the _any_summary backstop, so the run exits 0 with every other node's summary empty.

This narrows the check 3f33a53 added, which deliberately resolved the default (args.model or default_model()) so the no-flag case was covered, while d3880c6 in this PR simultaneously made flash + optimize=full the default. Resolving the default here would restore it.

PageIndex/run_pageindex.py

Lines 92 to 101 in 4430eba

from pageindex.flash import page_index_flash
summary_model = args.summary_model or args.index_model or args.model
will_summarize = args.summary if args.summary is not None else True
if summary_model and (will_summarize or args.optimize == 'full'):
import litellm
env = litellm.validate_environment(summary_model)
if not env["keys_in_environment"]:
raise SystemExit(
f"Missing API key for {summary_model}: {', '.join(env['missing_keys'])}")
toc_with_page_number = page_index_flash(

The SDK path in the same PR gets this right, validating the always-resolved self._summary_model:

generate_doc_description, write_node_id)
import litellm
if not self._index_backend:
env = litellm.validate_environment(self._summary_model)
if not env["keys_in_environment"]:
raise PageIndexAPIError(
f"Failed to submit document: missing API key for "
f"{self._summary_model}: {', '.join(env['missing_keys'])}")
result = page_index_flash(file_path, summary=True,

πŸ€– Generated with Claude Code

- If this code review was useful, please react with πŸ‘. Otherwise, react with πŸ‘Ž.

openai_agent_config(model=) went into the config raw while the
constructor default was normalized for the Agents SDK's prefix
resolver β€” the same slashed name worked from one knob and raised
UserError('Unknown prefix') from the other. The normalizer is
idempotent and prefix-preserving, so every input that worked before
resolves identically; only guaranteed-error inputs become working.
Found by an external agent review; the grammar test now covers the
override path it missed.
pypdfium2 5.x removed FPDFFont_GetFontName (now GetBaseFontName) and
changed PdfBookmark from attribute access to method calls. The existing
>=4.30.0 lower bound already resolved to 5.x, breaking fresh installs.
index_backend opted straight out of the module-level client cache
548d320 added: every llm_completion / llm_acompletion built a fresh
openai.OpenAI, and the indexing lane calls those once per node. A
200-node PDF paid ~46 ms of SSL-context construction each time (~9 s
of it blocking inside the summarize coroutines) and reused no
connection, re-handshaking TLS per summary. Output was always correct
and the clients close on refcount, so this was waste, not a leak.

The two bare globals become one dict keyed by (is_async, backend), so
the default path keeps the singleton it had and every distinct backend
gets its own reused client. The key is repr(sorted(items)) rather than
a JSON dump because a backend may legitimately carry a
non-serializable value (http_client=httpx.Client(...)), and sorting
item tuples only ever compares the unique string keys, never the
values. Capped at 8 entries and cleared wholesale past that: a backend
can carry per-tenant credentials, and thrashing degrades to today's
build-every-call rather than growing without bound.

is_async is keyword-only so neither call site reads as a bare boolean,
and both gateway branches collapse to one line each β€” product code is
net shorter.

Tests swap the whole dict via monkeypatch rather than clearing it, so
the fakes they cache are restored away with the attribute the way the
old globals were; clearing left them in the module, including under
the no-backend key every default call reads.
test_backend_scopes_the_index_lane also relied on construction
happening per call to capture the kwargs, so it swaps too and its
docstring drops "fresh".
PDFium returns astral characters (U+10000+, e.g. mathematical italic π‘ž)
as two consecutive UTF-16 surrogate code units instead of one UTF-32
codepoint. The lone surrogates survive through the pipeline and cause
UnicodeEncodeError when the OpenAI SDK serializes the summary prompt
(ensure_ascii=False + .encode('utf-8')), silently producing empty
summaries for every node whose pages contain these characters.

Detect high surrogates at the FPDFText_GetUnicode read site, peek the
next textpage slot for the low surrogate, and combine into the full
codepoint before chr(). Downstream processing (glyph width, span merge,
heading detection) now sees one correct character instead of two invalid
ones.
…ops masking fatal errors

Two fixes, same root: a missing or invalid API key on the default CLI
command could silently produce a tree with empty summaries.

run_pageindex.py β€” the missing-key pre-check never fired on
`python3 run_pageindex.py --pdf_path doc.pdf` because summary_model
was None (no CLI flag) and `if summary_model and (...)` short-circuited.
Now resolved via ConfigLoader β€” the same source page_index_flash uses
internally β€” so the pre-check and execution always agree on the model.
Also fixes a pre-existing issue where optimize_model=None was passed
to flash's expand stage.

pageindex/utils.py β€” summarize_tree's visit() caught every exception
(including 401 auth errors) and set summary="". A single leaf under
200 tokens β€” whose raw text is used as the summary without an LLM
call β€” then satisfied _any_summary, so the function returned
"successfully" with one real summary and the rest empty. Now visit()
re-raises unrecoverable errors (401/403/404), and the gather results
are checked before _any_summary. Transient errors (429, 500, timeout)
still degrade gracefully.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant