-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy path_core.py
More file actions
561 lines (469 loc) · 20.7 KB
/
_core.py
File metadata and controls
561 lines (469 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
"""Core infrastructure for NotebookLM API client."""
import asyncio
import logging
import time
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, cast
from urllib.parse import urlencode
import httpx
from .auth import AuthTokens
from .rpc import (
BATCHEXECUTE_URL,
AuthError,
ClientError,
NetworkError,
RateLimitError,
RPCError,
RPCMethod,
RPCTimeoutError,
ServerError,
build_request_body,
decode_response,
encode_rpc_request,
)
logger = logging.getLogger(__name__)
# Maximum number of conversations to cache (FIFO eviction)
MAX_CONVERSATION_CACHE_SIZE = 100
# Default HTTP timeouts in seconds
DEFAULT_TIMEOUT = 30.0
DEFAULT_CONNECT_TIMEOUT = 10.0 # Connection establishment timeout
# Auth error detection patterns (case-insensitive)
AUTH_ERROR_PATTERNS = (
"authentication",
"expired",
"unauthorized",
"login",
"re-authenticate",
)
def is_auth_error(error: Exception) -> bool:
"""Check if an exception indicates an authentication failure.
Args:
error: The exception to check.
Returns:
True if the error is likely due to authentication issues.
"""
# AuthError is always an auth error
if isinstance(error, AuthError):
return True
# Don't treat network/rate limit/server errors as auth errors
# even if they're subclasses of RPCError
if isinstance(
error,
NetworkError | RPCTimeoutError | RateLimitError | ServerError | ClientError,
):
return False
# HTTP 401/403 are auth errors
if isinstance(error, httpx.HTTPStatusError):
return error.response.status_code in (401, 403)
# RPCError with auth-related message
if isinstance(error, RPCError):
message = str(error).lower()
return any(pattern in message for pattern in AUTH_ERROR_PATTERNS)
return False
class ClientCore:
"""Core client infrastructure for HTTP and RPC operations.
Handles:
- HTTP client lifecycle (open/close)
- RPC call encoding/decoding
- Authentication headers
- Conversation cache
This class is used internally by the sub-client APIs (NotebooksAPI,
ArtifactsAPI, etc.) and should not be used directly.
"""
def __init__(
self,
auth: AuthTokens,
timeout: float = DEFAULT_TIMEOUT,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
refresh_callback: Callable[[], Awaitable[AuthTokens]] | None = None,
refresh_retry_delay: float = 0.2,
):
"""Initialize the core client.
Args:
auth: Authentication tokens from browser login.
timeout: HTTP request timeout in seconds. Defaults to 30 seconds.
This applies to read/write operations after connection is established.
connect_timeout: Connection establishment timeout in seconds. Defaults to 10 seconds.
A shorter connect timeout helps detect network issues faster.
refresh_callback: Optional async callback to refresh auth tokens on failure.
If provided, rpc_call will automatically retry once after refreshing.
refresh_retry_delay: Delay in seconds before retrying after refresh.
"""
self.auth = auth
self._timeout = timeout
self._connect_timeout = connect_timeout
self._refresh_callback = refresh_callback
self._refresh_retry_delay = refresh_retry_delay
self._refresh_lock: asyncio.Lock | None = asyncio.Lock() if refresh_callback else None
self._refresh_task: asyncio.Task[AuthTokens] | None = None
self._http_client: httpx.AsyncClient | None = None
# Request ID counter for chat API (must be unique per request)
self._reqid_counter: int = 100000
# OrderedDict for FIFO eviction when cache exceeds MAX_CONVERSATION_CACHE_SIZE
self._conversation_cache: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
async def open(self) -> None:
"""Open the HTTP client connection.
Called automatically by NotebookLMClient.__aenter__.
Uses httpx.Cookies jar to properly handle cross-domain redirects
(e.g., to accounts.google.com for auth token refresh).
"""
if self._http_client is None:
# Use granular timeouts: shorter connect timeout helps detect network issues
# faster, while longer read/write timeouts accommodate slow responses
timeout = httpx.Timeout(
connect=self._connect_timeout,
read=self._timeout,
write=self._timeout,
pool=self._timeout,
)
# Build cookies jar for cross-domain redirect support
cookies = self._build_cookies_jar()
self._http_client = httpx.AsyncClient(
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
cookies=cookies,
timeout=timeout,
)
async def close(self) -> None:
"""Close the HTTP client connection.
Called automatically by NotebookLMClient.__aexit__.
"""
if self._http_client:
await self._http_client.aclose()
self._http_client = None
@property
def is_open(self) -> bool:
"""Check if the HTTP client is open."""
return self._http_client is not None
def update_auth_headers(self) -> None:
"""Update HTTP client cookies with current auth tokens.
Call this after modifying auth tokens (e.g., after refresh_auth())
to ensure the HTTP client uses the updated credentials.
Uses httpx.Cookies jar to properly handle cross-domain redirects.
Raises:
RuntimeError: If client is not initialized.
"""
if not self._http_client:
raise RuntimeError("Client not initialized. Use 'async with' context.")
# Replace cookies jar with updated auth tokens
self._http_client.cookies = self._build_cookies_jar()
def _build_cookies_jar(self) -> httpx.Cookies:
"""Build an httpx.Cookies jar from auth tokens.
Uses .google.com as the domain to ensure cookies are sent
across all Google subdomains including accounts.google.com
for cross-domain auth refresh redirects.
Returns:
httpx.Cookies jar populated with auth cookies.
"""
cookies = httpx.Cookies()
for name, value in self.auth.cookies.items():
# Use .google.com domain to cover all subdomains including
# accounts.google.com (used for token refresh redirects)
cookies.set(name, value, domain=".google.com")
return cookies
def _build_url(self, rpc_method: RPCMethod, source_path: str = "/") -> str:
"""Build the batchexecute URL for an RPC call.
Args:
rpc_method: The RPC method to call.
source_path: The source path parameter (usually notebook path).
Returns:
Full URL with query parameters.
"""
params = {
"rpcids": rpc_method.value,
"source-path": source_path,
"f.sid": self.auth.session_id,
"rt": "c",
}
return f"{BATCHEXECUTE_URL}?{urlencode(params)}"
async def rpc_call(
self,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
allow_null: bool = False,
_is_retry: bool = False,
) -> Any:
"""Make an RPC call to the NotebookLM API.
Automatically refreshes authentication tokens and retries once if an
auth failure is detected and a refresh_callback was provided.
Args:
method: The RPC method to call.
params: Parameters for the RPC call (nested list structure).
source_path: The source path parameter (usually /notebook/{id}).
allow_null: If True, don't raise error when response is null.
_is_retry: Internal flag to prevent infinite retries.
Returns:
Decoded response data.
Raises:
RuntimeError: If client is not initialized (not in context manager).
httpx.HTTPStatusError: If HTTP request fails.
RPCError: If RPC call fails or returns unexpected data.
"""
if not self._http_client:
raise RuntimeError("Client not initialized. Use 'async with' context.")
start = time.perf_counter()
logger.debug("RPC %s starting", method.name)
url = self._build_url(method, source_path)
rpc_request = encode_rpc_request(method, params)
body = build_request_body(rpc_request, self.auth.csrf_token)
try:
response = await self._http_client.post(url, content=body)
response.raise_for_status()
except (httpx.HTTPStatusError, httpx.RequestError) as e:
elapsed = time.perf_counter() - start
# Check if this is an auth error and we can retry
if not _is_retry and self._refresh_callback and is_auth_error(e):
refreshed = await self._try_refresh_and_retry(
method, params, source_path, allow_null, e
)
if refreshed is not None:
return refreshed
if isinstance(e, httpx.HTTPStatusError):
status = e.response.status_code
logger.error(
"RPC %s failed after %.3fs: HTTP %s",
method.name,
elapsed,
status,
)
# Map HTTP status codes to appropriate exception types
if status == 429:
# Rate limiting - extract retry-after if available
retry_after = None
retry_after_header = e.response.headers.get("retry-after")
if retry_after_header:
try:
retry_after = int(retry_after_header)
except ValueError:
pass
msg = f"API rate limit exceeded calling {method.name}"
if retry_after:
msg += f". Retry after {retry_after} seconds"
raise RateLimitError(
msg, method_id=method.value, retry_after=retry_after
) from e
if 500 <= status < 600:
raise ServerError(
f"Server error {status} calling {method.name}: {e.response.reason_phrase}",
method_id=method.value,
status_code=status,
) from e
if 400 <= status < 500 and status not in (401, 403):
raise ClientError(
f"Client error {status} calling {method.name}: {e.response.reason_phrase}",
method_id=method.value,
status_code=status,
) from e
# 401/403 or other: Generic RPCError (handled by auth retry above)
raise RPCError(
f"HTTP {status} calling {method.name}: {e.response.reason_phrase}",
method_id=method.value,
) from e
# Network/connection errors
else:
logger.error("RPC %s failed after %.3fs: %s", method.name, elapsed, e)
# Check ConnectTimeout first (more specific than general TimeoutException)
if isinstance(e, httpx.ConnectTimeout):
raise NetworkError(
f"Connection timed out calling {method.name}: {e}",
method_id=method.value,
original_error=e,
) from e
# Timeout errors (general timeouts, not connection timeouts)
if isinstance(e, httpx.TimeoutException):
raise RPCTimeoutError(
f"Request timed out calling {method.name}",
method_id=method.value,
timeout_seconds=self._timeout,
original_error=e,
) from e
# Connection errors (DNS, network unavailable, etc., excluding ConnectTimeout)
if isinstance(e, httpx.ConnectError):
raise NetworkError(
f"Connection failed calling {method.name}: {e}",
method_id=method.value,
original_error=e,
) from e
# Other request errors
raise NetworkError(
f"Request failed calling {method.name}: {e}",
method_id=method.value,
original_error=e,
) from e
try:
result = decode_response(response.text, method.value, allow_null=allow_null)
elapsed = time.perf_counter() - start
logger.debug("RPC %s completed in %.3fs", method.name, elapsed)
return result
except RPCError as e:
elapsed = time.perf_counter() - start
# Check if this is an auth error and we can retry
if not _is_retry and self._refresh_callback and is_auth_error(e):
refreshed = await self._try_refresh_and_retry(
method, params, source_path, allow_null, e
)
if refreshed is not None:
return refreshed
logger.error("RPC %s failed after %.3fs", method.name, elapsed)
raise
except Exception as e:
elapsed = time.perf_counter() - start
logger.error("RPC %s failed after %.3fs: %s", method.name, elapsed, e)
raise RPCError(
f"Failed to decode response for {method.name}: {e}",
method_id=method.value,
) from e
async def _try_refresh_and_retry(
self,
method: RPCMethod,
params: list[Any],
source_path: str,
allow_null: bool,
original_error: Exception,
) -> Any | None:
"""Attempt to refresh auth tokens and retry the RPC call.
Uses a shared task pattern to ensure only one refresh operation runs
at a time. Concurrent callers wait on the same task, preventing
redundant refresh calls under high concurrency.
Args:
method: The RPC method to retry.
params: Original parameters.
source_path: Original source path.
allow_null: Original allow_null setting.
original_error: The auth error that triggered this retry.
Returns:
The RPC result if retry succeeds, None if refresh failed.
Raises:
The original error (with refresh error as cause) if refresh fails.
"""
logger.info(
"RPC %s auth error detected, attempting token refresh",
method.name,
)
# This function is only called when _refresh_callback is set
assert self._refresh_callback is not None
# Use lock to coordinate refresh task creation
# Note: refresh_callback is expected to update auth headers internally
# Lock is always created when callback is set (see __init__)
assert self._refresh_lock is not None
# Determine which task to await (existing or new)
async with self._refresh_lock:
if self._refresh_task is not None and not self._refresh_task.done():
# Another refresh is in progress, wait on it
refresh_task = self._refresh_task
logger.debug("Waiting on existing refresh task for RPC %s", method.name)
else:
# Start a new refresh task
# Cast needed: Awaitable → Coroutine for create_task (async funcs return coroutines)
coro = cast(Coroutine[Any, Any, AuthTokens], self._refresh_callback())
self._refresh_task = asyncio.create_task(coro)
refresh_task = self._refresh_task
# Await refresh outside the lock so other callers can join
try:
await refresh_task
except Exception as refresh_error:
logger.warning("Token refresh failed: %s", refresh_error)
raise original_error from refresh_error
# Brief delay before retry to avoid hammering the API
if self._refresh_retry_delay > 0:
await asyncio.sleep(self._refresh_retry_delay)
logger.info("Token refresh successful, retrying RPC %s", method.name)
# Retry with refreshed tokens
return await self.rpc_call(method, params, source_path, allow_null, _is_retry=True)
def get_http_client(self) -> httpx.AsyncClient:
"""Get the underlying HTTP client for direct requests.
Used by download operations that need direct HTTP access.
Returns:
The httpx.AsyncClient instance.
Raises:
RuntimeError: If client is not initialized.
"""
if not self._http_client:
raise RuntimeError("Client not initialized. Use 'async with' context.")
return self._http_client
def cache_conversation_turn(
self, conversation_id: str, query: str, answer: str, turn_number: int
) -> None:
"""Cache a conversation turn locally.
Uses FIFO eviction when cache exceeds MAX_CONVERSATION_CACHE_SIZE.
Args:
conversation_id: The conversation ID.
query: The user's question.
answer: The AI's response.
turn_number: The turn number in the conversation.
"""
is_new_conversation = conversation_id not in self._conversation_cache
# Only evict when adding a NEW conversation at capacity
if is_new_conversation:
while len(self._conversation_cache) >= MAX_CONVERSATION_CACHE_SIZE:
# popitem(last=False) removes oldest entry (FIFO)
self._conversation_cache.popitem(last=False)
self._conversation_cache[conversation_id] = []
self._conversation_cache[conversation_id].append(
{
"query": query,
"answer": answer,
"turn_number": turn_number,
}
)
def get_cached_conversation(self, conversation_id: str) -> list[dict[str, Any]]:
"""Get cached conversation turns.
Args:
conversation_id: The conversation ID.
Returns:
List of cached turns, or empty list if not found.
"""
return self._conversation_cache.get(conversation_id, [])
def clear_conversation_cache(self, conversation_id: str | None = None) -> bool:
"""Clear conversation cache.
Args:
conversation_id: Clear specific conversation, or all if None.
Returns:
True if cache was cleared.
"""
if conversation_id:
if conversation_id in self._conversation_cache:
del self._conversation_cache[conversation_id]
return True
return False
else:
self._conversation_cache.clear()
return True
async def get_source_ids(self, notebook_id: str) -> list[str]:
"""Extract all source IDs from a notebook.
Fetches notebook data and extracts source IDs for use with
chat and artifact generation when targeting specific sources.
Args:
notebook_id: The notebook ID.
Returns:
List of source IDs. Empty list if no sources or on error.
Note:
Source IDs are triple-nested in RPC: source[0][0] contains the ID.
"""
params = [notebook_id, None, [2], None, 0]
notebook_data = await self.rpc_call(
RPCMethod.GET_NOTEBOOK,
params,
source_path=f"/notebook/{notebook_id}",
)
source_ids: list[str] = []
if not notebook_data or not isinstance(notebook_data, list):
return source_ids
try:
if len(notebook_data) > 0 and isinstance(notebook_data[0], list):
notebook_info = notebook_data[0]
if len(notebook_info) > 1 and isinstance(notebook_info[1], list):
sources = notebook_info[1]
for source in sources:
if isinstance(source, list) and len(source) > 0:
first = source[0]
if isinstance(first, list) and len(first) > 0:
sid = first[0]
if isinstance(sid, str):
source_ids.append(sid)
except (IndexError, TypeError):
pass
return source_ids