Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
814f1c8
feat(py): Allow deferred chat client initialization (#205)
cpsievert Feb 3, 2026
77547c2
fix(py): require client before Shiny app setup
cpsievert Apr 15, 2026
f9ec235
refactor(py): Defer client initialization and support lazy defaults
cpsievert Apr 15, 2026
65c8415
refactor(py): Make normalize_client always return a fresh Chat
cpsievert Apr 15, 2026
a2ca0a3
refactor(py): Rename normalize_client to create_client
cpsievert Apr 15, 2026
b0ebb19
refactor(py): Rename _require_client to _ensure_client
cpsievert Apr 15, 2026
95d62b1
refactor(py): Use create_client for forking in client() and generate_…
cpsievert Apr 15, 2026
506c4df
refactor(py): Replace _client with _client_spec, eliminate state matrix
cpsievert Apr 16, 2026
48b3168
fix(py): Move _require_data_source check to public methods
cpsievert Apr 16, 2026
4148df2
docs(py): Add caller-guard comment to _create_session_client
cpsievert Apr 16, 2026
8a3949d
fix(py): restore deferred client behavior
cpsievert Apr 16, 2026
e30a39a
test(py): lock down shiny session-local client semantics
cpsievert Apr 16, 2026
40e7d88
test(py): refine deferred shiny client semantics tests
cpsievert Apr 16, 2026
39b4f9a
fix(py): keep shiny server client overrides session-local
cpsievert Apr 16, 2026
e7805e7
fix(py): clarify server client requirement
cpsievert Apr 16, 2026
1edad32
fix(py): align server docs and types
cpsievert Apr 16, 2026
fbffc9a
test(py): cover deferred shiny client resolution helpers
cpsievert Apr 16, 2026
0b7d978
test(py): verify session-local shiny client fix
cpsievert Apr 16, 2026
73b5338
style(py): satisfy ruff for deferred client fix
cpsievert Apr 16, 2026
a96cb3a
merge: resolve conflict in test_base.py
cpsievert Apr 16, 2026
95e7c34
refactor(py): remove _require_client_spec, allow None as valid default
cpsievert Apr 16, 2026
6e16e4b
fix(py): remove stale docstring about client=None, improve readability
cpsievert Apr 16, 2026
0138127
test(py): update tests for None-as-valid-default client behavior
cpsievert Apr 16, 2026
ceb0a09
docs(py): revert unnecessary explicit client= in examples and docs
cpsievert Apr 16, 2026
716d543
style(py): fix ruff formatting in test_deferred_client
cpsievert Apr 16, 2026
3faee22
refactor(py): collapse _create_session_client_from_spec into _create_…
cpsievert Apr 16, 2026
881c04e
fix(py): restore _require_data_source check in console() for error cl…
cpsievert Apr 16, 2026
63ff49d
docs(py): remove unnecessary client= from server() docstring example
cpsievert Apr 16, 2026
59769dc
docs(py): add deferred client feature to changelog
cpsievert Apr 16, 2026
fc9b5b7
refactor(r): defer client resolution, add create_session_client()
cpsievert Apr 16, 2026
28d69c9
feat(r): add client parameter to $server() for session-local overrides
cpsievert Apr 16, 2026
e3ea543
style(r): format deferred client changes with air
cpsievert Apr 16, 2026
fee90c5
fix(r): clone Chat in create_session_client() to prevent mutation
cpsievert Apr 16, 2026
8079c3b
fix(py): keep deferred client resolution lazy
cpsievert Apr 16, 2026
e93d60b
`air format` (GitHub Actions)
cpsievert Apr 16, 2026
9b66f37
`devtools::document()` (GitHub Actions)
cpsievert Apr 16, 2026
7ada432
docs(r): add deferred client feature to NEWS.md
cpsievert Apr 16, 2026
b7ff8d1
docs(r): mention deferred client in build vignette
cpsievert Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### New features

* `QueryChat()` now supports deferred chat client initialization. Pass `client=` to `server()` to provide a session-scoped chat client, enabling use cases where API credentials are only available at session time (e.g., Posit Connect managed OAuth tokens). When no `client` is specified anywhere, querychat resolves a sensible default from the `QUERYCHAT_CLIENT` environment variable (or `"openai"`). (#205)

### Improvements

* When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208)
Expand Down
2 changes: 2 additions & 0 deletions pkg-py/docs/build.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ app = App(app_ui, server)

</details>

If your chat client also depends on session-scoped credentials, you can defer that too by passing it to `qc.server(client=...)` alongside the `data_source`.

:::

:::
Expand Down
87 changes: 51 additions & 36 deletions pkg-py/src/querychat/_querychat_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,7 @@ def __init__(
self._extra_instructions = extra_instructions
self._categorical_threshold = categorical_threshold

# Normalize and initialize client (doesn't need data_source)
client = normalize_client(client)
self._client = copy.deepcopy(client)
self._client.set_turns([])

self._client_spec: str | chatlas.Chat | None = client
self._client_console = None

# Initialize data source (may be None for deferred pattern)
Expand Down Expand Up @@ -114,7 +110,6 @@ def _build_system_prompt(self) -> None:
extra_instructions=self._extra_instructions,
categorical_threshold=self._categorical_threshold,
)
self._client.system_prompt = self._system_prompt.render(self.tools)

def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]:
"""Raise if data_source is not set, otherwise return it for type narrowing."""
Expand All @@ -126,6 +121,39 @@ def _require_data_source(self, method_name: str) -> DataSource[IntoFrameT]:
)
return self._data_source

def _create_session_client(
self,
*,
client_spec: str | chatlas.Chat | None | MISSING_TYPE = MISSING,
tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING,
update_dashboard: Callable[[UpdateDashboardData], None] | None = None,
reset_dashboard: Callable[[], None] | None = None,
) -> chatlas.Chat:
"""Create a fresh, fully-configured Chat."""
spec = self._client_spec if isinstance(client_spec, MISSING_TYPE) else client_spec
chat = create_client(spec)

resolved_tools = normalize_tools(tools, default=self.tools)

if self._system_prompt is not None:
chat.system_prompt = self._system_prompt.render(resolved_tools)

if resolved_tools is None:
return chat

data_source = self._require_data_source("_create_session_client")

if "update" in resolved_tools:
update_fn = update_dashboard or (lambda _: None)
reset_fn = reset_dashboard or (lambda: None)
chat.register_tool(tool_update_dashboard(data_source, update_fn))
chat.register_tool(tool_reset_dashboard(reset_fn))

if "query" in resolved_tools:
chat.register_tool(tool_query(data_source))

return chat

def client(
self,
*,
Expand All @@ -151,35 +179,20 @@ def client(
A configured chat client.

"""
data_source = self._require_data_source("client")
if self._system_prompt is None:
raise RuntimeError("System prompt not initialized")
tools = normalize_tools(tools, default=self.tools)

chat = copy.deepcopy(self._client)
chat.set_turns([])
chat.system_prompt = self._system_prompt.render(tools)

if tools is None:
return chat

if "update" in tools:
update_fn = update_dashboard or (lambda _: None)
reset_fn = reset_dashboard or (lambda: None)
chat.register_tool(tool_update_dashboard(data_source, update_fn))
chat.register_tool(tool_reset_dashboard(reset_fn))

if "query" in tools:
chat.register_tool(tool_query(data_source))

return chat
self._require_data_source("client")
return self._create_session_client(
tools=tools,
update_dashboard=update_dashboard,
reset_dashboard=reset_dashboard,
)

def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str:
"""Generate a welcome greeting for the chat."""
self._require_data_source("generate_greeting")
client = copy.deepcopy(self._client)
client.set_turns([])
return str(client.chat(GREETING_PROMPT, echo=echo))
chat = create_client(self._client_spec)
if self._system_prompt is not None:
chat.system_prompt = self._system_prompt.render(self.tools)
return str(chat.chat(GREETING_PROMPT, echo=echo))

def console(
self,
Expand All @@ -190,8 +203,6 @@ def console(
) -> None:
"""Launch an interactive console chat with the data."""
self._require_data_source("console")
tools = normalize_tools(tools, default=("query",))

if new or self._client_console is None:
self._client_console = self.client(tools=tools, **kwargs)

Expand Down Expand Up @@ -262,17 +273,21 @@ def normalize_data_source(
)


def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat:
def create_client(client: str | chatlas.Chat | None) -> chatlas.Chat:
"""Resolve a client spec into a fresh Chat with no conversation history."""
if client is None:
client = os.getenv("QUERYCHAT_CLIENT", None)

if client is None:
client = "openai"

if isinstance(client, chatlas.Chat):
return client
chat = copy.deepcopy(client)
else:
chat = chatlas.ChatAuto(provider_model=client)

return chatlas.ChatAuto(provider_model=client)
chat.set_turns([])
return chat


def normalize_tools(
Expand Down
21 changes: 17 additions & 4 deletions pkg-py/src/querychat/_shiny.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ._icons import bs_icon
from ._querychat_base import TOOL_GROUPS, QueryChatBase
from ._shiny_module import ServerValues, mod_server, mod_ui
from ._utils import as_narwhals
from ._utils import MISSING, MISSING_TYPE, as_narwhals

if TYPE_CHECKING:
from pathlib import Path
Expand Down Expand Up @@ -299,7 +299,7 @@ def app_server(input: Inputs, output: Outputs, session: Session):
self.id,
data_source=data_source,
greeting=self.greeting,
client=self._client,
client=self._create_session_client,
enable_bookmarking=enable_bookmarking,
)

Expand Down Expand Up @@ -405,6 +405,7 @@ def server(
self,
*,
data_source: Optional[IntoFrame | sqlalchemy.Engine | ibis.Table] = None,
client: str | chatlas.Chat | MISSING_TYPE = MISSING,
enable_bookmarking: bool = False,
id: Optional[str] = None,
) -> ServerValues[IntoFrameT]:
Expand All @@ -422,6 +423,12 @@ def server(
Optional data source to use. If provided, sets the data_source property
before initializing server logic. This is useful for the deferred pattern
where data_source is not known at initialization time.
client
Optional chat client to use for this session. If provided, overrides
any client set at initialization time for this call only. This is useful
for the deferred pattern where the client cannot be created at
initialization time (e.g., when using Posit Connect managed OAuth
credentials that require session access).
enable_bookmarking
Whether to enable bookmarking for the querychat module.
id
Expand Down Expand Up @@ -486,12 +493,18 @@ def title():
self.data_source = data_source

resolved_data_source = self._require_data_source("server")
resolved_client_spec = self._client_spec if isinstance(client, MISSING_TYPE) else client

def create_session_client(**kwargs) -> chatlas.Chat:
return self._create_session_client(
client_spec=resolved_client_spec, **kwargs
)

return mod_server(
id or self.id,
data_source=resolved_data_source,
greeting=self.greeting,
client=self.client,
client=create_session_client,
enable_bookmarking=enable_bookmarking,
)

Expand Down Expand Up @@ -728,7 +741,7 @@ def __init__(
self.id,
data_source=self._data_source,
greeting=self.greeting,
client=self._client,
client=self._create_session_client,
enable_bookmarking=enable,
)

Expand Down
8 changes: 5 additions & 3 deletions pkg-py/src/querychat/_shiny_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,16 @@ class ServerValues(Generic[IntoFrameT]):
client
The session-specific chat client instance. This is a deep copy of the
base client configured for this specific session, containing the chat
history and tool registrations for this session only.
history and tool registrations for this session only. This may be
`None` during stub sessions when the client depends on deferred,
session-scoped state.

"""

df: Callable[[], IntoFrameT]
sql: ReactiveStringOrNone
title: ReactiveStringOrNone
client: chatlas.Chat
client: chatlas.Chat | None


@module.server
Expand Down Expand Up @@ -115,7 +117,7 @@ def _stub_df():
df=_stub_df,
sql=sql,
title=title,
client=client if isinstance(client, chatlas.Chat) else client(),
client=client if isinstance(client, chatlas.Chat) else None,
)

# Real session requires data_source
Expand Down
22 changes: 14 additions & 8 deletions pkg-py/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from querychat._datasource import DataFrameSource, SQLAlchemySource
from querychat._querychat_base import (
QueryChatBase,
normalize_client,
create_client,
normalize_data_source,
normalize_tools,
)
Expand Down Expand Up @@ -110,26 +110,26 @@ def test_with_special_column_names(self):

class TestNormalizeClient:
def test_with_none_uses_default(self):
result = normalize_client(None)
result = create_client(None)
assert isinstance(result, chatlas.Chat)

def test_with_string_provider(self):
result = normalize_client("openai")
result = create_client("openai")
assert isinstance(result, chatlas.Chat)

def test_with_chat_instance(self):
chat = chatlas.ChatOpenAI()
result = normalize_client(chat)
result = create_client(chat)
assert isinstance(result, chatlas.Chat)

def test_respects_env_variable(self, monkeypatch):
monkeypatch.setenv("QUERYCHAT_CLIENT", "openai")
result = normalize_client(None)
result = create_client(None)
assert isinstance(result, chatlas.Chat)

def test_with_invalid_provider_raises(self):
with pytest.raises(ValueError, match="is not a known chatlas provider"):
normalize_client("not_a_real_provider_xyz123")
create_client("not_a_real_provider_xyz123")


class TestNormalizeTools:
Expand Down Expand Up @@ -207,9 +207,15 @@ def test_client_with_callbacks(self, sample_df):
update_called = []
reset_called = []

def update_dashboard(data):
update_called.append(data)

def reset_dashboard():
reset_called.append(True)

client = qc.client(
update_dashboard=update_called.append,
reset_dashboard=lambda: reset_called.append(True),
update_dashboard=update_dashboard,
reset_dashboard=reset_dashboard,
)
assert isinstance(client, chatlas.Chat)

Expand Down
Loading
Loading