Skip to content

feat: the client grows two sides β€” documents and chat each pick their home - #424

Merged
rejojer merged 11 commits into
mainfrom
feat/client-config
Aug 25, 2026
Merged

feat: the client grows two sides β€” documents and chat each pick their home#424
rejojer merged 11 commits into
mainfrom
feat/client-config

Conversation

@rejojer

@rejojer rejojer commented Aug 24, 2026

Copy link
Copy Markdown
Member

One client, two independent switches: api_key decides where your documents live; a configured chat model decides who answers. Their free combination opens the third mode β€” cloud documents, your own model β€” and the impossible fourth (local documents + managed chat) stays unspellable.

PageIndexClient(index_model="gpt-5-mini", chat_model="openai/gpt-5.2")  # local: free, your models
PageIndexClient(api_key="pi-...", chat_model="openai/gpt-5.2")          # cloud docs, keep your model
PageIndexClient(api_key="pi-...")                                        # fully managed

Each step of that ladder changes one thing. chat_model means the same thing everywhere β€” your model, on your keys, in your process; api_key moves your documents, never your model.

New configuration surface

  • index= / chat= slots β€” string shorthand or grouped dict, 1:1 with the flat arguments (same names, same meanings). One spelling per side; sides mix freely.
  • Optional "mode" β€” top-level (mode="cloud") or inside either dict. Always omittable, checked against the content (disagreement errors), meaningful alone: mode="cloud" / index={"mode": "cloud"} are keyless cloud spellings.
  • PAGEINDEX_API_KEY is read only when the code explicitly says cloud β€” PageIndexCloudClient() (now the shortest env-key construction), mode="cloud", "cloud" / "pageindex-cloud", {"mode": "cloud"}. A bare PageIndexClient() stays local no matter what the environment holds.
  • Mode words β€” "cloud" and "local" are accepted wherever "pageindex-cloud" is (index=, chat=, mode=, {"mode": ...}), any case/whitespace; the near-synonyms "hosted" / "managed" error pointing at the real word instead of silently parsing as a model name.
  • The pinned classes take the slots β€” PageIndexLocalClient(index=..., chat=...) and PageIndexCloudClient(index=...) accept the grouped spelling of the flat vocabulary they already take; a slot that picks the other side is refused in the class's name, and the mode cross-check runs before any environment read.
  • Typed shapes β€” IndexConfig / CloudIndexConfig / LocalIndexConfig / ChatConfig ship as optional annotations (Literal-typed mode fields give checkers a discriminated union). The constructor keeps accepting plain dicts. The package ships py.typed, so installed callers' checkers see them.
  • Strict validation β€” unknown keys, mixed sides, empty dicts, mode/content conflicts, and missing keys all fail loudly with the legal vocabulary in the message. Nothing is ever silently guessed. Strings are stripped in every spelling, and a slot value's error names the slot key (index["model"]), not the flat argument.

The bridge (cloud documents + your model)

The same in-process document-QA engine that local mode runs, fed by the live cloud MCP tool set and the MCP server's own instructions. chat(), chat_completions(), responses() and messages() all work. Semantics, documented on the surfaces:

  • Page content flows through your process to your model provider, on your provider credentials (an auth-shaped backend failure explains exactly this).
  • doc_id targets at the prompt level (the tool-layer allowlist is local-store only).
  • enable_citations stays managed-only.
  • An empty MCP tools/list raises, like empty instructions β€” a zero-tool agent would answer from the model's own knowledge with nothing to signal it.

Compatibility

Not strictly additive:

  • chat_backend={}, index_backend={}, storage_path="", model="" β€” and every other value that configures nothing ("", whitespace-only strings, {}) β€” now refuse at construction; main accepted them in local mode.
  • On a cloud client, client.retrieve_model = m (0.2.9's write path) now selects own-model chat instead of doing nothing (a blank value keeps the managed chat); api_key + chat_model and api_key + chat_backend graduate from a constructor error into the bridge.
  • Error-path guidance is reworded throughout.

Tests

432 passing (main's 393 untouched, 39 new). New coverage: constructor matrix, slot/mode/env rules, mode words, the pinned classes' slots, and bridge execution on all three chat lanes against a fake MCP bridge. A 28,000-combination constructor sweep confirms every input either builds a consistent client or raises PageIndexAPIError β€” never a third kind of failure. Construction touches no network; clients stay picklable.

Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/client.py Fixed
Comment thread pageindex/flash/parser_pdfium_parallel.py Fixed
Comment thread tests/test_flash_extraction.py Fixed
… home

One client, two independent switches: api_key decides where documents
live (the PageIndex cloud, or the local store); a configured chat model
decides who answers (your own model in your process, or the managed
cloud chat). Their free combination opens the bridge β€” cloud documents,
your model β€” and the fourth cell stays unspellable.

- index=/chat= slots: string shorthand or grouped dict, 1:1 with the
  flat arguments; one spelling per side, sides mix freely
- optional "type" everywhere (top-level and in either dict): always
  omittable, checked against the content, meaningful alone β€”
  type="cloud" is a keyless cloud spelling
- PAGEINDEX_API_KEY is read only when the code explicitly says cloud
  (PageIndexCloudClient(), type="cloud", "pageindex-cloud",
  {"type": "cloud"}); a bare PageIndexClient() stays local
- bare mode words ("cloud", "local", …) are reserved: they error
  with the real spellings instead of silently parsing as model names
- bridge chat runs the in-process agent over the live cloud MCP tools
  and instructions; doc_id targets at the prompt level; citations stay
  managed-only; an auth-shaped backend failure explains whose
  credentials run the model
- typed shapes (IndexConfig, ChatConfig) ship as optional annotations

Every previously working program is byte-for-byte unchanged: the only
behavioral deltas are error paths β€” reworded guidance, and the
api_key+chat_model combination graduating from an error into the
bridge.
@rejojer
rejojer force-pushed the feat/client-config branch from b951fdb to c5cd78d Compare August 24, 2026 10:55
@rejojer

rejojer commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

πŸ€– Generated with Claude Code

- .env keys reach all four keyless-cloud spellings: utils' import-time
  load_dotenv now runs before every PAGEINDEX_API_KEY read
- an empty chat-side value ("", {}) errors instead of silently selecting
  own-model chat on the default model; None-valued slot keys mean absent,
  exactly like the flat arguments
- _local_chat derives from chat_model, so a post-construction assignment
  switches the whole client, never half of it
- model= beside a slot gets the split guidance (index_model=/chat_model=)
  instead of "two spellings of the same thing"
- the messages door wraps provider failures through _model_backend_error,
  and 401s count as auth-shaped even without "api key" in the text
- keyless-cloud hints name the spelling that actually combines; slot
  strings are stripped; wrong-typed values raise PageIndexAPIError
- retrieve_model/chat_backend docs drop the stale "Local mode only";
  the local-scope refusal no longer claims bridge tools are server-scoped

Claude-Session: https://claude.ai/code/session_01RnBVRYfvzSZhNewbD7qni7
…its chat side

- type= beside index= now does what the docstring promises: agreement
  passes, disagreement errors, and a mistyped value reports the
  vocabulary error instead of a spelling collision
- PageIndexCloudClient grows the chat-side arguments (chat=, chat_model,
  retrieve_model, chat_backend), so "pin the index side" is literally
  true and the chat surfaces' construct-with-chat_model guidance is
  followable on it
- the four chat doors' doc_id entries carry the enforcement split the
  config helpers already state (local: tool-layer allowlist; cloud:
  prompt-level / server-side)
- types.py stops claiming slot keys share the flat names β€” the side
  prefix is factored out, index={"model"} is index_model=

Claude-Session: https://claude.ai/code/session_01LJ6FLPk8LN4GN4tfswjS1J
…, hints name a chat= model

- the three framework-missing errors said "in local mode", which is
  wrong on a bridge client (cloud documents + own model) β€” they now
  explain the dependency the way the surfaces do: your own chat model
- the construct-with guidance reads "(or a chat= model)": a bare
  chat="pageindex-cloud" is also chat= but selects the managed side
- the mechanical Local-only β†’ Own-model-chat-only substitution left
  orphan fragments and two overlong lines; those paragraphs re-flowed

Claude-Session: https://claude.ai/code/session_01LJ6FLPk8LN4GN4tfswjS1J
…ists

- a managed-chat cloud client stores chat_model/chat_backend as None, so
  the documented attribute reads instead of raising AttributeError;
  _local_chat derives from "is a chat model configured"
- McpBridge caches tools/list per session β€” every chat turn rebuilds the
  tool set, and the round trip was pure latency; the 404 session-expiry
  reset drops the cache with the session
- run_messages builds tools before the transport: on a bridge client
  that build is network I/O, and a failure there stranded a per-call
  anthropic client ahead of the try/finally

Claude-Session: https://claude.ai/code/session_01RnBVRYfvzSZhNewbD7qni7
@rejojer

rejojer commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

πŸ€– Generated with Claude Code

"type" is Python's own word β€” a builtin, and "data type" beside the
TypedDict shapes; "mode" is what the SDK already calls the two sides
("local mode", "cloud mode"). Same grammar everywhere the declaration
appears: the top-level argument, the index dict, the chat dict, the
typed shapes. The rename also frees the builtin inside the constructor,
so the shape-check error names the offending class through type() again.
"type" in a slot dict is now an ordinary unknown key.
With the declaration key spelled mode=, 'index="cloud" is not a mode
word' contradicted its own remedy, index={"mode": "cloud"} β€” "cloud" is
exactly a mode value. The four bare strings are reserved words; the
message now says so.
…s chat-lane only

- McpBridge.list_tools caches only a non-empty list β€” a transient blank
  (a deploy blip, a gate misconfiguration) would otherwise run every later
  turn with zero tools while the instructions still name them, and only a
  404 session reset could clear it
- the 401 architecture note appends "drop the chat model configuration"
  only on the chat lane: responses() and messages() refuse a client
  without an own model, so on those lanes the exit sent the caller in a
  circle
- CloudIndexConfig says api_key is omittable only while mode: "cloud"
  stays β€” index={} refuses as an empty dict rather than reading the env

Claude-Session: https://claude.ai/code/session_01E1op9sc12bv7EeY27ZhWns
… the cwd

- McpBridge.list_tools no longer caches: the tool set is built once per
  SDK call (Agent(tools=...) ahead of Runner.run; build_anthropic_tools
  ahead of tool_runner), not per model turn, so the cache saved one round
  trip per later call while a mid-pagination 404 replayed a dead cursor
  into a duplicated (and cached) list, and the list went out by reference
  across a lock dropped between miss and store
- utils.load_dotenv searches upward from the cwd: a bare load_dotenv()
  walked up from utils.py, which is site-packages for an installed SDK,
  so the four keyless-cloud spellings never saw a project-root .env; the
  package-relative walk stays as the fallback
- the emptiness guard strips strings: chat_model=" " selected own-model
  chat, the silent flip the guard's own comment rules out
- the _local_chat comment stops advertising post-construction assignment
  as a full mode switch

Claude-Session: https://claude.ai/code/session_01836Yrt6Swz3RmRnysnRGSo
@rejojer

rejojer commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

πŸ€– Generated with Claude Code

…ords; a blank chat_model stays managed

- PageIndexLocalClient takes index= and chat=, PageIndexCloudClient takes
  index= β€” the grouped spelling of the flat vocabulary each already took;
  their refusals name the class and an exit that class can take, and the
  mode cross-check runs before any environment read
- "cloud" and "local" are accepted wherever "pageindex-cloud" was (index=,
  chat=, mode=, {"mode": ...}), case- and whitespace-insensitive; "hosted"
  and "managed" still refuse, pointing at the real word
- every spelling strips its strings, and the slot spellings' type/empty
  errors name the slot key (index["model"]), not the flat argument
- _local_chat treats a blank chat_model as managed: the constructor
  refuses "", so assignment agrees instead of opening the bridge on a
  nameless model; openai_agent_config carries no model then either
- an empty MCP tools/list raises like empty instructions does β€” a
  zero-tool agent would answer from the model's own knowledge silently
- enable_citations names the real gate (managed vs own chat), not
  "cloud-only", on a cloud own-model client
- pageindex/py.typed: the exported config TypedDicts reach installed
  type-checked callers

Claude-Session: https://claude.ai/code/session_01GZLsJ6jmAQvgotcbhQv85Q
Comment thread pageindex/client.py
return value


_CloudKey = Union[str, Callable[[], str], None]
Comment thread tests/test_agent_tools.py
"""An empty tools/list must raise like empty instructions does: a
zero-tool agent answers from the model's own knowledge instead of
the documents, with nothing to signal it."""
import pageindex.mcp_bridge as mcp_bridge
as_openai_tools() and openai_agent_config() need the agents package, which
the "without frameworks" CI legs do not install β€” the same importorskip
every other test on those doors already carries.

Claude-Session: https://claude.ai/code/session_01GZLsJ6jmAQvgotcbhQv85Q
@rejojer
rejojer merged commit 920db2b into main Aug 25, 2026
9 checks passed
@rejojer

rejojer commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

πŸ€– Generated with Claude Code

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