feat(librarian): send explicit non-empty user on every library-desk request

Library-desk is removing its server-side default user, so a request
without an explicit tenant will 422 after its next deploy:

- New client-level _resolve_user() resolves the tenant (explicit arg
  or request context) and raises ValueError on an empty/whitespace
  value BEFORE any bytes hit the wire; all 15 tenant-scoped methods
  use it
- extract_content / extract_content_batch now accept and send the
  user (query param), matching the rest of the API surface
- search_web no longer falls back to a phantom "tatlock-librarian"
  tenant; it sends the resolved user
- health_check stays user-less (public, not tenant-scoped)

Tests: parametrized sweep pins the wire contract (user present in
params or payload) for every tenant-scoped method, for both context
and explicit users; empty-tenant calls are asserted to fail without
any HTTP call; the recorded-fixture hybrid contract test now pins
user as an explicit query param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:02:46 +02:00
co-authored by Claude Fable 5
parent 4b786a766c
commit b9eae38556
4 changed files with 190 additions and 23 deletions
+43 -15
View File
@@ -263,6 +263,23 @@ class LibraryDeskClient:
)
return self._client
def _resolve_user(self, user: str | None) -> str:
"""
Resolve the effective tenant for a request and require it non-empty.
Library-desk is removing its server-side default user, so every
request must carry an explicit tenant (a missing user will 422).
An empty tenant is a programming or configuration error - fail
loudly here, before any bytes hit the wire.
"""
effective = user if user is not None else get_user()
if not effective or not effective.strip():
raise ValueError(
"library-desk request requires a non-empty user (tenant); "
"got an empty value from the caller or request context"
)
return effective
async def _request_with_retry(
self,
send: Callable[[], Awaitable[httpx.Response]],
@@ -337,7 +354,7 @@ class LibraryDeskClient:
Returns:
HybridRAGResponse with ranked results and context
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
# The live service requires all limits >= 1 (422 otherwise);
@@ -446,7 +463,7 @@ class LibraryDeskClient:
Returns:
List of matching wiki pages
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
logger.debug("library_desk_wiki_search", query=query, user=user)
@@ -478,7 +495,7 @@ class LibraryDeskClient:
Returns:
WikiPage with full content
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
response = await self._request_with_retry(
@@ -509,7 +526,7 @@ class LibraryDeskClient:
Returns:
List of wiki pages
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
params: dict[str, Any] = {"user": user, "limit": limit}
@@ -548,7 +565,7 @@ class LibraryDeskClient:
Returns:
Created WikiPage
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
@@ -593,7 +610,7 @@ class LibraryDeskClient:
Returns:
Updated WikiPage
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
# Build update payload with only provided fields
@@ -651,7 +668,7 @@ class LibraryDeskClient:
Returns:
SmartCreateResponse with page and research metadata
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
payload: dict[str, Any] = {
@@ -702,7 +719,7 @@ class LibraryDeskClient:
Returns:
List of dossiers with page counts
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
response = await self._request_with_retry(
@@ -740,7 +757,7 @@ class LibraryDeskClient:
Returns:
List of matching document chunks with scores
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
@@ -781,7 +798,7 @@ class LibraryDeskClient:
Returns:
List of result records
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
@@ -814,7 +831,7 @@ class LibraryDeskClient:
Returns:
List of graph nodes
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
params: dict[str, Any] = {"user": user, "limit": limit}
@@ -845,7 +862,7 @@ class LibraryDeskClient:
Returns:
Node with relationships and connected nodes
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
response = await self._request_with_retry(
@@ -907,14 +924,14 @@ class LibraryDeskClient:
Returns:
WebSearchResponse with results and pre-formatted sources
"""
user = user or get_user()
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
"query": query,
"search_type": search_type,
"limit": limit,
"user": user or "tatlock-librarian",
"user": user,
}
logger.info("library_desk_web_search", query=query, limit=limit)
@@ -955,6 +972,7 @@ class LibraryDeskClient:
async def extract_content(
self,
url: str,
user: str | None = None,
include_metadata: bool = True,
max_length: int = 5000,
) -> ContentExtractionResult:
@@ -968,12 +986,14 @@ class LibraryDeskClient:
Args:
url: URL to extract content from
user: User identifier (defaults to request context)
include_metadata: Whether to extract author, date, etc.
max_length: Maximum content length
Returns:
ContentExtractionResult (check .success and .error fields)
"""
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
@@ -984,7 +1004,11 @@ class LibraryDeskClient:
logger.debug("library_desk_extract_content", url=url)
response = await client.post("/content/extract", json=payload)
response = await client.post(
"/content/extract",
json=payload,
params={"user": user},
)
response.raise_for_status()
data = response.json()
@@ -1004,6 +1028,7 @@ class LibraryDeskClient:
async def extract_content_batch(
self,
urls: list[str],
user: str | None = None,
include_metadata: bool = True,
max_length: int = 2000,
) -> BatchExtractionResponse:
@@ -1017,12 +1042,14 @@ class LibraryDeskClient:
Args:
urls: List of URLs to extract (max 20)
user: User identifier (defaults to request context)
include_metadata: Whether to extract author, date, etc.
max_length: Maximum content length per URL
Returns:
BatchExtractionResponse with results and stats
"""
user = self._resolve_user(user)
client = self._ensure_client()
payload = {
@@ -1036,6 +1063,7 @@ class LibraryDeskClient:
response = await client.post(
"/content/extract/batch",
json=payload,
params={"user": user},
)
response.raise_for_status()