diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md
index c3346cfde..a02fe5f68 100644
--- a/pkg-py/CHANGELOG.md
+++ b/pkg-py/CHANGELOG.md
@@ -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)
diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd
index 3e4cb6de0..009f6cfd0 100644
--- a/pkg-py/docs/build.qmd
+++ b/pkg-py/docs/build.qmd
@@ -154,6 +154,8 @@ app = App(app_ui, server)
+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`.
+
:::
:::
diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py
index e8a7c7f15..d3bf29e26 100644
--- a/pkg-py/src/querychat/_querychat_base.py
+++ b/pkg-py/src/querychat/_querychat_base.py
@@ -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)
@@ -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."""
@@ -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,
*,
@@ -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,
@@ -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)
@@ -262,7 +273,8 @@ 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)
@@ -270,9 +282,12 @@ def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat:
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(
diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py
index c1dcc9a19..c25b923fc 100644
--- a/pkg-py/src/querychat/_shiny.py
+++ b/pkg-py/src/querychat/_shiny.py
@@ -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
@@ -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,
)
@@ -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]:
@@ -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
@@ -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,
)
@@ -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,
)
diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py
index 335f6803a..4264285bd 100644
--- a/pkg-py/src/querychat/_shiny_module.py
+++ b/pkg-py/src/querychat/_shiny_module.py
@@ -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
@@ -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
diff --git a/pkg-py/tests/test_base.py b/pkg-py/tests/test_base.py
index daff6ee07..1aa75c562 100644
--- a/pkg-py/tests/test_base.py
+++ b/pkg-py/tests/test_base.py
@@ -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,
)
@@ -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:
@@ -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)
diff --git a/pkg-py/tests/test_deferred_client.py b/pkg-py/tests/test_deferred_client.py
new file mode 100644
index 000000000..bdd2f6aa0
--- /dev/null
+++ b/pkg-py/tests/test_deferred_client.py
@@ -0,0 +1,136 @@
+"""Tests for deferred chat client initialization."""
+
+import pandas as pd
+import pytest
+from chatlas import ChatOpenAI
+from querychat._querychat_base import QueryChatBase
+
+
+@pytest.fixture
+def sample_df():
+ """Create a sample pandas DataFrame for testing."""
+ return pd.DataFrame(
+ {
+ "id": [1, 2, 3],
+ "name": ["Alice", "Bob", "Charlie"],
+ "age": [25, 30, 35],
+ },
+ )
+
+
+class TestDeferredClientInit:
+ """Tests for initializing QueryChatBase with deferred client."""
+
+ def test_init_with_none_data_source_defers_client(self):
+ """When data_source is None and client is not provided, _client_spec should be None."""
+ qc = QueryChatBase(None, "users")
+ assert qc._client_spec is None
+
+ def test_init_with_explicit_client_and_none_data_source(self):
+ """When data_source is None but client is provided, _client_spec should be stored."""
+ qc = QueryChatBase(None, "users", client="openai")
+ assert qc._client_spec == "openai"
+
+ def test_init_with_chat_object_stores_spec(self, monkeypatch):
+ """When a Chat object is passed, it should be stored as-is."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing")
+ chat = ChatOpenAI()
+ qc = QueryChatBase(None, "users", client=chat)
+ assert qc._client_spec is chat
+
+ def test_init_with_data_source_no_client(self, sample_df):
+ """When data_source is provided without client, _client_spec should be None."""
+ qc = QueryChatBase(sample_df, "users")
+ assert qc._client_spec is None
+
+ def test_init_with_invalid_explicit_client_is_still_lazy(self):
+ """Explicit client specs should be stored lazily and fail only when resolved."""
+ qc = QueryChatBase(None, "users", client="not_a_real_provider_xyz123")
+ assert qc._client_spec == "not_a_real_provider_xyz123"
+
+class TestClientMethodRequirements:
+ """Tests that methods properly require data_source to be set."""
+
+ def test_client_method_requires_data_source(self):
+ """client() should raise if data_source is not set."""
+ qc = QueryChatBase(None, "users")
+
+ with pytest.raises(RuntimeError, match="data_source must be set"):
+ qc.client()
+
+ def test_console_requires_data_source(self):
+ """console() should raise if data_source is not set."""
+ qc = QueryChatBase(None, "users")
+
+ with pytest.raises(RuntimeError, match="data_source must be set"):
+ qc.console()
+
+ def test_generate_greeting_requires_data_source(self):
+ """generate_greeting() should raise if data_source is not set."""
+ qc = QueryChatBase(None, "users")
+
+ with pytest.raises(RuntimeError, match="data_source must be set"):
+ qc.generate_greeting()
+
+
+class TestDeferredClientIntegration:
+ """Integration tests for the full deferred client workflow."""
+
+ def test_deferred_data_source_uses_default_client(self, sample_df, monkeypatch):
+ """Test setting the data source later still resolves the default client."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing")
+
+ qc = QueryChatBase(None, "users")
+ assert qc.data_source is None
+ assert qc._client_spec is None
+
+ qc.data_source = sample_df
+
+ client = qc.client()
+ assert client is not None
+ assert "users" in qc.system_prompt
+
+ def test_deferred_explicit_client_at_init_then_data_source(self, sample_df, monkeypatch):
+ """Test an explicit deferred client passed at init still works later."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing")
+
+ qc = QueryChatBase(None, "users", client="openai")
+ qc.data_source = sample_df
+
+ client = qc.client()
+ assert client is not None
+
+ def test_no_openai_key_error_when_deferred(self, monkeypatch):
+ """When data_source is None, no OpenAI API key error should occur."""
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.delenv("QUERYCHAT_CLIENT", raising=False)
+
+ qc = QueryChatBase(None, "users")
+ assert qc._client_spec is None
+
+ def test_invalid_explicit_client_raises_when_client_is_resolved(self, sample_df):
+ """Invalid explicit client specs should fail when a live client is requested."""
+ qc = QueryChatBase(None, "users", client="not_a_real_provider_xyz123")
+ qc.data_source = sample_df
+
+ with pytest.raises(ValueError, match="is not a known chatlas provider"):
+ qc.client()
+
+
+class TestBackwardCompatibility:
+ """Tests that existing patterns continue to work."""
+
+ def test_immediate_pattern_unchanged(self, sample_df, monkeypatch):
+ """Existing code with data_source continues to work."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-dummy-key-for-testing")
+ qc = QueryChatBase(sample_df, "test_table")
+
+ assert qc.data_source is not None
+ # _client_spec is None (will use env default at resolution time)
+ assert qc._client_spec is None
+
+ client = qc.client()
+ assert client is not None
+
+ prompt = qc.system_prompt
+ assert "test_table" in prompt
diff --git a/pkg-py/tests/test_deferred_datasource.py b/pkg-py/tests/test_deferred_datasource.py
index af46b0d4b..522c98ec9 100644
--- a/pkg-py/tests/test_deferred_datasource.py
+++ b/pkg-py/tests/test_deferred_datasource.py
@@ -139,15 +139,13 @@ class TestDeferredPatternIntegration:
def test_deferred_then_set_property(self, sample_df):
"""Test setting data_source via property after init."""
- # Create with None
qc = QueryChatBase(None, "users")
assert qc.data_source is None
+ assert qc._client_spec is None
- # Set via property
qc.data_source = sample_df
assert qc.data_source is not None
- # Now methods should work
client = qc.client()
assert client is not None
assert "users" in qc.system_prompt
@@ -157,13 +155,10 @@ def test_data_source_change_rebuilds_prompt(self, sample_df):
qc = QueryChatBase(sample_df, "original")
original_prompt = qc.system_prompt
- # Change data source (same table name)
new_df = pd.DataFrame({"different": [1, 2], "columns": [3, 4]})
qc.data_source = new_df
new_prompt = qc.system_prompt
- # Prompt should be different (different schema)
assert original_prompt != new_prompt
- # But table name should be preserved
assert "original" in new_prompt
diff --git a/pkg-py/tests/test_deferred_shiny.py b/pkg-py/tests/test_deferred_shiny.py
index 541e94acd..39899a772 100644
--- a/pkg-py/tests/test_deferred_shiny.py
+++ b/pkg-py/tests/test_deferred_shiny.py
@@ -4,7 +4,12 @@
import pandas as pd
import pytest
+from chatlas import ChatOpenAI
from querychat import QueryChat
+from querychat._querychat_base import create_client as _create_client
+from querychat.express import QueryChat as ExpressQueryChat
+from shiny.express._stub_session import ExpressStubSession
+from shiny.session import session_context
@pytest.fixture(autouse=True)
@@ -61,3 +66,50 @@ def test_app_requires_data_source(self):
qc = QueryChat(None, "users")
with pytest.raises(RuntimeError, match="data_source must be set"):
qc.app()
+
+ def test_express_allows_deferred_data_source_during_stub_session(self):
+ """Express should allow deferred initialization during the stub session."""
+ with session_context(ExpressStubSession()):
+ qc = ExpressQueryChat(None, "users")
+
+ assert qc is not None
+
+ def test_server_client_override_does_not_mutate_shared_client_spec(
+ self, sample_df, monkeypatch
+ ):
+ """server(client=...) should stay lazy during the stub session."""
+ init_client = ChatOpenAI(model="gpt-4.1")
+ override_client = ChatOpenAI(model="gpt-4.1-mini")
+ qc = QueryChat(None, "users", client=init_client)
+ recorded_specs = []
+ real_create_client = _create_client
+
+ def spy_create_client(client_spec):
+ recorded_specs.append(client_spec)
+ return real_create_client(client_spec)
+
+ monkeypatch.setattr(
+ "querychat._querychat_base.create_client", spy_create_client
+ )
+
+ with session_context(ExpressStubSession()):
+ vals = qc.server(data_source=sample_df, client=override_client)
+
+ assert vals.client is None
+ assert recorded_specs == []
+ assert qc._client_spec is init_client
+
+ def test_multiple_server_overrides_do_not_leak_into_shared_state(self, sample_df):
+ """Sequential overrides should not overwrite the instance-level client spec."""
+ init_client = ChatOpenAI(model="gpt-4.1")
+ first_override = ChatOpenAI(model="gpt-4.1-mini")
+ second_override = ChatOpenAI(model="gpt-4.1-nano")
+ qc = QueryChat(None, "users", client=init_client)
+
+ with session_context(ExpressStubSession()):
+ qc.server(data_source=sample_df, client=first_override)
+
+ with session_context(ExpressStubSession()):
+ qc.server(data_source=sample_df, client=second_override)
+
+ assert qc._client_spec is init_client
diff --git a/pkg-py/tests/test_querychat.py b/pkg-py/tests/test_querychat.py
index 243423e1e..7d8b94258 100644
--- a/pkg-py/tests/test_querychat.py
+++ b/pkg-py/tests/test_querychat.py
@@ -1,4 +1,5 @@
import os
+from unittest.mock import patch
import pandas as pd
import polars as pl
@@ -86,10 +87,44 @@ def test_querychat_client_has_system_prompt(sample_df):
# The system_prompt should contain the table name since it includes schema info
assert "test_table" in client.system_prompt
- # The internal _client should also have the system prompt set
- # (needed for methods like generate_greeting() that use _client directly)
- assert qc._client.system_prompt is not None
- assert "test_table" in qc._client.system_prompt
+
+def test_generate_greeting_uses_querychat_system_prompt(sample_df):
+ """generate_greeting() should use the dataset-aware querychat system prompt."""
+ qc = QueryChat(
+ data_source=sample_df,
+ table_name="test_table",
+ greeting="Hello!",
+ )
+ seen: dict[str, str | None] = {}
+
+ def fake_chat(self, *args, **kwargs):
+ seen["system_prompt"] = self.system_prompt
+ return "Hello from querychat"
+
+ with patch("chatlas.Chat.chat", fake_chat):
+ greeting = qc.generate_greeting()
+
+ assert greeting == "Hello from querychat"
+ assert seen["system_prompt"] is not None
+ assert "test_table" in seen["system_prompt"]
+
+
+def test_generate_greeting_does_not_register_querychat_tools(sample_df):
+ """generate_greeting() should use a plain chat client without dashboard/query tools."""
+ qc = QueryChat(
+ data_source=sample_df,
+ table_name="test_table",
+ greeting="Hello!",
+ )
+
+ with (
+ patch("chatlas.Chat.register_tool") as register_tool,
+ patch("chatlas.Chat.chat", return_value="Hello from querychat"),
+ ):
+ greeting = qc.generate_greeting()
+
+ assert greeting == "Hello from querychat"
+ register_tool.assert_not_called()
def test_querychat_with_polars_lazyframe():
diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md
index 6620d2159..639010cac 100644
--- a/pkg-r/NEWS.md
+++ b/pkg-r/NEWS.md
@@ -1,5 +1,7 @@
# querychat (development version)
+* `QueryChat$server()` now accepts a `client` parameter for session-scoped chat client overrides. This enables Posit Connect managed OAuth workflows where API credentials are only available inside the Shiny server function. The client spec is stored lazily at construction time and resolved only when needed, so `QueryChat$new(NULL, "table")` no longer requires an API key. (#205)
+
* 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)
* Added support for Snowflake Semantic Views. When connected to Snowflake via DBI, querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200)
diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R
index 20574442a..a471c4dd2 100644
--- a/pkg-r/R/QueryChat.R
+++ b/pkg-r/R/QueryChat.R
@@ -92,7 +92,7 @@ QueryChat <- R6::R6Class(
server_values = NULL,
.data_source = NULL,
.table_name = NULL,
- .client = NULL,
+ .client_spec = NULL,
.client_console = NULL,
.system_prompt = NULL,
# Store init parameters for deferred system prompt building
@@ -132,6 +132,44 @@ QueryChat <- R6::R6Class(
extra_instructions = private$.extra_instructions,
categorical_threshold = private$.categorical_threshold
)
+ },
+
+ create_session_client = function(
+ client_spec = NULL,
+ tools = NA,
+ update_dashboard = function(query, title) {},
+ reset_dashboard = function() {}
+ ) {
+ spec <- client_spec %||% private$.client_spec
+ chat <- as_querychat_client(spec)
+ chat <- chat$clone()
+ chat$set_turns(list())
+
+ if (is_na(tools)) {
+ tools <- self$tools
+ }
+
+ chat$set_system_prompt(private$.system_prompt$render(tools = tools))
+
+ if (is.null(tools)) {
+ return(chat)
+ }
+
+ if ("update" %in% tools) {
+ chat$register_tool(
+ tool_update_dashboard(
+ private$.data_source,
+ update_fn = update_dashboard
+ )
+ )
+ chat$register_tool(tool_reset_dashboard(reset_dashboard))
+ }
+
+ if ("query" %in% tools) {
+ chat$register_tool(tool_query(private$.data_source))
+ }
+
+ chat
}
),
public = list(
@@ -253,10 +291,7 @@ QueryChat <- R6::R6Class(
private$build_system_prompt()
}
- # Fork and empty chat now so the per-session forks are fast
- client <- as_querychat_client(client)
- private$.client <- client$clone()
- private$.client$set_turns(list())
+ private$.client_spec <- client
# By default, only close automatically if a Shiny session is active
if (is.na(cleanup)) {
@@ -290,10 +325,7 @@ QueryChat <- R6::R6Class(
) {
private$require_data_source("$client")
- if (is_na(tools)) {
- tools <- self$tools
- }
- if (!is.null(tools)) {
+ if (!is_na(tools) && !is.null(tools)) {
tools <- arg_match(
tools,
values = c("update", "query"),
@@ -301,28 +333,11 @@ QueryChat <- R6::R6Class(
)
}
- chat <- private$.client$clone()
- chat$set_system_prompt(private$.system_prompt$render(tools = tools))
-
- if (is.null(tools)) {
- return(chat)
- }
-
- if ("update" %in% tools) {
- chat$register_tool(
- tool_update_dashboard(
- private$.data_source,
- update_fn = update_dashboard
- )
- )
- chat$register_tool(tool_reset_dashboard(reset_dashboard))
- }
-
- if ("query" %in% tools) {
- chat$register_tool(tool_query(private$.data_source))
- }
-
- chat
+ private$create_session_client(
+ tools = tools,
+ update_dashboard = update_dashboard,
+ reset_dashboard = reset_dashboard
+ )
},
#' @description
@@ -409,10 +424,12 @@ QueryChat <- R6::R6Class(
ui <- function(req) {
bslib::page_sidebar(
- title = shiny::HTML(sprintf(
- "querychat with %s",
- table_name
- )),
+ title = shiny::HTML(
+ sprintf(
+ "querychat with %s",
+ table_name
+ )
+ ),
class = "bslib-page-dashboard",
sidebar = self$sidebar(),
shiny::useBusyIndicators(pulse = TRUE, spinners = FALSE),
@@ -508,12 +525,14 @@ QueryChat <- R6::R6Class(
})
shiny::observeEvent(input$close_btn, label = "on_close_btn", {
- shiny::stopApp(list(
- df = qc_vals$df(),
- sql = qc_vals$sql(),
- title = qc_vals$title(),
- client = qc_vals$client
- ))
+ shiny::stopApp(
+ list(
+ df = qc_vals$df(),
+ sql = qc_vals$sql(),
+ title = qc_vals$title(),
+ client = qc_vals$client
+ )
+ )
})
}
@@ -623,6 +642,11 @@ QueryChat <- R6::R6Class(
#' for the deferred pattern where data_source is not known at
#' initialization time (e.g., when the data source depends on session-
#' specific authentication).
+ #' @param client Optional chat client override for this session. Can be an
+ #' [ellmer::Chat] object or a string (e.g., `"openai/gpt-4o"`). If provided,
+ #' overrides the client set at initialization for this session only —
+ #' other sessions are unaffected. This is useful when the client must be
+ #' created within a session scope (e.g., Posit Connect managed credentials).
#' @param enable_bookmarking Whether to enable bookmarking for the chat
#' state. Default is `FALSE`. When enabled, the chat state (including
#' current query, title, and chat history) will be saved and restored
@@ -646,6 +670,7 @@ QueryChat <- R6::R6Class(
#'
server = function(
data_source = NULL,
+ client = NULL,
enable_bookmarking = FALSE,
...,
id = NULL,
@@ -666,11 +691,20 @@ QueryChat <- R6::R6Class(
private$require_data_source("$server")
+ resolved_client_spec <- client %||% private$.client_spec
+
+ create_session_client <- function(...) {
+ private$create_session_client(
+ client_spec = resolved_client_spec,
+ ...
+ )
+ }
+
mod_server(
id %||% self$id,
data_source = private$.data_source,
greeting = self$greeting,
- client = self$client,
+ client = create_session_client,
enable_bookmarking = enable_bookmarking
)
},
@@ -701,9 +735,7 @@ QueryChat <- R6::R6Class(
#' @return The greeting string in Markdown format.
generate_greeting = function(echo = c("none", "output")) {
private$require_data_source("$generate_greeting")
- chat <- self$client()
- chat$set_turns(list())
-
+ chat <- private$create_session_client()
as.character(chat$chat(GREETING_PROMPT, echo = echo))
},
@@ -943,7 +975,6 @@ normalize_data_source <- function(data_source, table_name) {
)
}
-
namespaced_id <- function(id, session = shiny::getDefaultReactiveDomain()) {
if (is.null(session)) {
id
diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd
index 2d782737b..da67c32e1 100644
--- a/pkg-r/man/QueryChat.Rd
+++ b/pkg-r/man/QueryChat.Rd
@@ -466,6 +466,7 @@ server <- function(input, output, session) \{
\subsection{Usage}{
\if{html}{\out{