sentienceapi 0.90.9__py3-none-any.whl → 0.90.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sentienceapi might be problematic. Click here for more details.

sentience/browser.py CHANGED
@@ -11,7 +11,8 @@ from urllib.parse import urlparse
11
11
 
12
12
  from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright
13
13
 
14
- from sentience.models import ProxyConfig, StorageState
14
+ from sentience._extension_loader import find_extension_path
15
+ from sentience.models import ProxyConfig, StorageState, Viewport
15
16
 
16
17
  # Import stealth for bot evasion (optional - graceful fallback if not available)
17
18
  try:
@@ -33,6 +34,9 @@ class SentienceBrowser:
33
34
  proxy: str | None = None,
34
35
  user_data_dir: str | None = None,
35
36
  storage_state: str | Path | StorageState | dict | None = None,
37
+ record_video_dir: str | Path | None = None,
38
+ record_video_size: dict[str, int] | None = None,
39
+ viewport: Viewport | dict[str, int] | None = None,
36
40
  ):
37
41
  """
38
42
  Initialize Sentience browser
@@ -57,6 +61,19 @@ class SentienceBrowser:
57
61
  - StorageState object
58
62
  - Dictionary with 'cookies' and/or 'origins' keys
59
63
  If provided, browser starts with pre-injected authentication.
64
+ record_video_dir: Optional directory path to save video recordings.
65
+ If provided, browser will record video of all pages.
66
+ Videos are saved as .webm files in the specified directory.
67
+ If None, no video recording is performed.
68
+ record_video_size: Optional video resolution as dict with 'width' and 'height' keys.
69
+ Examples: {"width": 1280, "height": 800} (default)
70
+ {"width": 1920, "height": 1080} (1080p)
71
+ If None, defaults to 1280x800.
72
+ viewport: Optional viewport size as Viewport object or dict with 'width' and 'height' keys.
73
+ Examples: Viewport(width=1280, height=800) (default)
74
+ Viewport(width=1920, height=1080) (Full HD)
75
+ {"width": 1280, "height": 800} (dict also supported)
76
+ If None, defaults to Viewport(width=1280, height=800).
60
77
  """
61
78
  self.api_key = api_key
62
79
  # Only set api_url if api_key is provided, otherwise None (free tier)
@@ -80,6 +97,18 @@ class SentienceBrowser:
80
97
  self.user_data_dir = user_data_dir
81
98
  self.storage_state = storage_state
82
99
 
100
+ # Video recording support
101
+ self.record_video_dir = record_video_dir
102
+ self.record_video_size = record_video_size or {"width": 1280, "height": 800}
103
+
104
+ # Viewport configuration - convert dict to Viewport if needed
105
+ if viewport is None:
106
+ self.viewport = Viewport(width=1280, height=800)
107
+ elif isinstance(viewport, dict):
108
+ self.viewport = Viewport(width=viewport["width"], height=viewport["height"])
109
+ else:
110
+ self.viewport = viewport
111
+
83
112
  self.playwright: Playwright | None = None
84
113
  self.context: BrowserContext | None = None
85
114
  self.page: Page | None = None
@@ -133,28 +162,8 @@ class SentienceBrowser:
133
162
 
134
163
  def start(self) -> None:
135
164
  """Launch browser with extension loaded"""
136
- # Get extension source path (relative to project root/package)
137
- # Handle both development (src/) and installed package cases
138
-
139
- # 1. Try relative to this file (installed package structure)
140
- # sentience/browser.py -> sentience/extension/
141
- package_ext_path = Path(__file__).parent / "extension"
142
-
143
- # 2. Try development root (if running from source repo)
144
- # sentience/browser.py -> ../sentience-chrome
145
- dev_ext_path = Path(__file__).parent.parent.parent / "sentience-chrome"
146
-
147
- if package_ext_path.exists() and (package_ext_path / "manifest.json").exists():
148
- extension_source = package_ext_path
149
- elif dev_ext_path.exists() and (dev_ext_path / "manifest.json").exists():
150
- extension_source = dev_ext_path
151
- else:
152
- raise FileNotFoundError(
153
- f"Extension not found. Checked:\n"
154
- f"1. {package_ext_path}\n"
155
- f"2. {dev_ext_path}\n"
156
- "Make sure the extension is built and 'sentience/extension' directory exists."
157
- )
165
+ # Get extension source path using shared utility
166
+ extension_source = find_extension_path()
158
167
 
159
168
  # Create temporary extension bundle
160
169
  # We copy it to a temp dir to avoid file locking issues and ensure clean state
@@ -197,7 +206,7 @@ class SentienceBrowser:
197
206
  "user_data_dir": user_data_dir,
198
207
  "headless": False, # IMPORTANT: See note above
199
208
  "args": args,
200
- "viewport": {"width": 1280, "height": 800},
209
+ "viewport": {"width": self.viewport.width, "height": self.viewport.height},
201
210
  # Remove "HeadlessChrome" from User Agent automatically
202
211
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
203
212
  }
@@ -209,6 +218,17 @@ class SentienceBrowser:
209
218
  launch_params["ignore_https_errors"] = True
210
219
  print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
211
220
 
221
+ # Add video recording if configured
222
+ if self.record_video_dir:
223
+ video_dir = Path(self.record_video_dir)
224
+ video_dir.mkdir(parents=True, exist_ok=True)
225
+ launch_params["record_video_dir"] = str(video_dir)
226
+ launch_params["record_video_size"] = self.record_video_size
227
+ print(f"🎥 [Sentience] Recording video to: {video_dir}")
228
+ print(
229
+ f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
230
+ )
231
+
212
232
  # Launch persistent context (required for extensions)
213
233
  # Note: We pass headless=False to launch_persistent_context because we handle
214
234
  # headless mode via the --headless=new arg above. This is a Playwright workaround.
@@ -390,15 +410,162 @@ class SentienceBrowser:
390
410
 
391
411
  return False
392
412
 
393
- def close(self) -> None:
394
- """Close browser and cleanup"""
413
+ def close(self, output_path: str | Path | None = None) -> str | None:
414
+ """
415
+ Close browser and cleanup
416
+
417
+ Args:
418
+ output_path: Optional path to rename the video file to.
419
+ If provided, the recorded video will be moved to this location.
420
+ Useful for giving videos meaningful names instead of random hashes.
421
+
422
+ Returns:
423
+ Path to video file if recording was enabled, None otherwise
424
+ Note: Video files are saved automatically by Playwright when context closes.
425
+ If multiple pages exist, returns the path to the first page's video.
426
+ """
427
+ temp_video_path = None
428
+
429
+ # Get video path before closing (if recording was enabled)
430
+ # Note: Playwright saves videos when pages/context close, but we can get the
431
+ # expected path before closing. The actual file will be available after close.
432
+ if self.record_video_dir:
433
+ try:
434
+ # Try to get video path from the first page
435
+ if self.page and self.page.video:
436
+ temp_video_path = self.page.video.path()
437
+ # If that fails, check all pages in the context
438
+ elif self.context:
439
+ for page in self.context.pages:
440
+ if page.video:
441
+ temp_video_path = page.video.path()
442
+ break
443
+ except Exception:
444
+ # Video path might not be available until after close
445
+ # In that case, we'll return None and user can check the directory
446
+ pass
447
+
448
+ # Close context (this triggers video file finalization)
395
449
  if self.context:
396
450
  self.context.close()
451
+
452
+ # Close playwright
397
453
  if self.playwright:
398
454
  self.playwright.stop()
455
+
456
+ # Clean up extension directory
399
457
  if self._extension_path and os.path.exists(self._extension_path):
400
458
  shutil.rmtree(self._extension_path)
401
459
 
460
+ # Rename/move video if output_path is specified
461
+ final_path = temp_video_path
462
+ if temp_video_path and output_path and os.path.exists(temp_video_path):
463
+ try:
464
+ output_path = str(output_path)
465
+ # Ensure parent directory exists
466
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
467
+ shutil.move(temp_video_path, output_path)
468
+ final_path = output_path
469
+ except Exception as e:
470
+ import warnings
471
+
472
+ warnings.warn(f"Failed to rename video file: {e}")
473
+ # Return original path if rename fails
474
+ final_path = temp_video_path
475
+
476
+ return final_path
477
+
478
+ @classmethod
479
+ def from_existing(
480
+ cls,
481
+ context: BrowserContext,
482
+ api_key: str | None = None,
483
+ api_url: str | None = None,
484
+ ) -> "SentienceBrowser":
485
+ """
486
+ Create SentienceBrowser from an existing Playwright BrowserContext.
487
+
488
+ This allows you to use Sentience SDK with a browser context you've already created,
489
+ giving you more control over browser initialization.
490
+
491
+ Args:
492
+ context: Existing Playwright BrowserContext
493
+ api_key: Optional API key for server-side processing
494
+ api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
495
+
496
+ Returns:
497
+ SentienceBrowser instance configured to use the existing context
498
+
499
+ Example:
500
+ from playwright.sync_api import sync_playwright
501
+ from sentience import SentienceBrowser, snapshot
502
+
503
+ with sync_playwright() as p:
504
+ context = p.chromium.launch_persistent_context(...)
505
+ browser = SentienceBrowser.from_existing(context)
506
+ browser.page.goto("https://example.com")
507
+ snap = snapshot(browser)
508
+ """
509
+ instance = cls(api_key=api_key, api_url=api_url)
510
+ instance.context = context
511
+ instance.page = context.pages[0] if context.pages else context.new_page()
512
+
513
+ # Apply stealth if available
514
+ if STEALTH_AVAILABLE:
515
+ stealth_sync(instance.page)
516
+
517
+ # Wait for extension to be ready (if extension is loaded)
518
+ time.sleep(0.5)
519
+
520
+ return instance
521
+
522
+ @classmethod
523
+ def from_page(
524
+ cls,
525
+ page: Page,
526
+ api_key: str | None = None,
527
+ api_url: str | None = None,
528
+ ) -> "SentienceBrowser":
529
+ """
530
+ Create SentienceBrowser from an existing Playwright Page.
531
+
532
+ This allows you to use Sentience SDK with a page you've already created,
533
+ giving you more control over browser initialization.
534
+
535
+ Args:
536
+ page: Existing Playwright Page
537
+ api_key: Optional API key for server-side processing
538
+ api_url: Optional API URL (defaults to https://api.sentienceapi.com if api_key provided)
539
+
540
+ Returns:
541
+ SentienceBrowser instance configured to use the existing page
542
+
543
+ Example:
544
+ from playwright.sync_api import sync_playwright
545
+ from sentience import SentienceBrowser, snapshot
546
+
547
+ with sync_playwright() as p:
548
+ browser_instance = p.chromium.launch()
549
+ context = browser_instance.new_context()
550
+ page = context.new_page()
551
+ page.goto("https://example.com")
552
+
553
+ browser = SentienceBrowser.from_page(page)
554
+ snap = snapshot(browser)
555
+ """
556
+ instance = cls(api_key=api_key, api_url=api_url)
557
+ instance.page = page
558
+ instance.context = page.context
559
+
560
+ # Apply stealth if available
561
+ if STEALTH_AVAILABLE:
562
+ stealth_sync(instance.page)
563
+
564
+ # Wait for extension to be ready (if extension is loaded)
565
+ time.sleep(0.5)
566
+
567
+ return instance
568
+
402
569
  def __enter__(self):
403
570
  """Context manager entry"""
404
571
  self.start()
@@ -213,7 +213,10 @@ class CloudTraceSink(TraceSink):
213
213
  if on_progress:
214
214
  on_progress(compressed_size, compressed_size)
215
215
 
216
- # Call /v1/traces/complete to report file sizes (NEW)
216
+ # Upload trace index file
217
+ self._upload_index()
218
+
219
+ # Call /v1/traces/complete to report file sizes
217
220
  self._complete_trace()
218
221
 
219
222
  # Delete file only on successful upload
@@ -244,6 +247,93 @@ class CloudTraceSink(TraceSink):
244
247
  # Non-fatal: log but don't crash
245
248
  print(f"⚠️ Failed to generate trace index: {e}")
246
249
 
250
+ def _upload_index(self) -> None:
251
+ """
252
+ Upload trace index file to cloud storage.
253
+
254
+ Called after successful trace upload to provide fast timeline rendering.
255
+ The index file enables O(1) step lookups without parsing the entire trace.
256
+ """
257
+ # Construct index file path (same as trace file with .index.json extension)
258
+ index_path = Path(str(self._path).replace(".jsonl", ".index.json"))
259
+
260
+ if not index_path.exists():
261
+ if self.logger:
262
+ self.logger.warning("Index file not found, skipping index upload")
263
+ return
264
+
265
+ try:
266
+ # Request index upload URL from API
267
+ if not self.api_key:
268
+ # No API key - skip index upload
269
+ if self.logger:
270
+ self.logger.info("No API key provided, skipping index upload")
271
+ return
272
+
273
+ response = requests.post(
274
+ f"{self.api_url}/v1/traces/index_upload",
275
+ headers={"Authorization": f"Bearer {self.api_key}"},
276
+ json={"run_id": self.run_id},
277
+ timeout=10,
278
+ )
279
+
280
+ if response.status_code != 200:
281
+ if self.logger:
282
+ self.logger.warning(
283
+ f"Failed to get index upload URL: HTTP {response.status_code}"
284
+ )
285
+ return
286
+
287
+ upload_data = response.json()
288
+ index_upload_url = upload_data.get("upload_url")
289
+
290
+ if not index_upload_url:
291
+ if self.logger:
292
+ self.logger.warning("No upload URL in index upload response")
293
+ return
294
+
295
+ # Read and compress index file
296
+ with open(index_path, "rb") as f:
297
+ index_data = f.read()
298
+
299
+ compressed_index = gzip.compress(index_data)
300
+ index_size = len(compressed_index)
301
+
302
+ if self.logger:
303
+ self.logger.info(f"Index file size: {index_size / 1024:.2f} KB")
304
+
305
+ print(f"📤 [Sentience] Uploading trace index ({index_size} bytes)...")
306
+
307
+ # Upload index to cloud storage
308
+ index_response = requests.put(
309
+ index_upload_url,
310
+ data=compressed_index,
311
+ headers={
312
+ "Content-Type": "application/json",
313
+ "Content-Encoding": "gzip",
314
+ },
315
+ timeout=30,
316
+ )
317
+
318
+ if index_response.status_code == 200:
319
+ print("✅ [Sentience] Trace index uploaded successfully")
320
+
321
+ # Delete local index file after successful upload
322
+ try:
323
+ os.remove(index_path)
324
+ except Exception:
325
+ pass # Ignore cleanup errors
326
+ else:
327
+ if self.logger:
328
+ self.logger.warning(f"Index upload failed: HTTP {index_response.status_code}")
329
+ print(f"⚠️ [Sentience] Index upload failed: HTTP {index_response.status_code}")
330
+
331
+ except Exception as e:
332
+ # Non-fatal: log but don't crash
333
+ if self.logger:
334
+ self.logger.warning(f"Error uploading trace index: {e}")
335
+ print(f"⚠️ [Sentience] Error uploading trace index: {e}")
336
+
247
337
  def _complete_trace(self) -> None:
248
338
  """
249
339
  Call /v1/traces/complete to report file sizes to gateway.
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from .agent import SentienceAgent
11
11
  from .browser import SentienceBrowser
12
12
  from .llm_provider import LLMProvider
13
- from .models import Snapshot
13
+ from .models import Snapshot, SnapshotOptions
14
14
  from .snapshot import snapshot
15
15
 
16
16
 
@@ -274,7 +274,7 @@ Create a step-by-step execution plan."""
274
274
  elif action == "EXTRACT_INFO":
275
275
  info_type = params["info_type"]
276
276
  # Get current page snapshot and extract info
277
- snap = snapshot(self.browser, limit=50)
277
+ snap = snapshot(self.browser, SnapshotOptions(limit=50))
278
278
 
279
279
  # Use LLM to extract specific information
280
280
  extracted = self._extract_information(snap, info_type)
@@ -361,7 +361,7 @@ Return JSON with extracted information:
361
361
  True if condition is met, False otherwise
362
362
  """
363
363
  try:
364
- snap = snapshot(self.browser, limit=30)
364
+ snap = snapshot(self.browser, SnapshotOptions(limit=30))
365
365
 
366
366
  # Build context
367
367
  elements_text = "\n".join([f"{el.role}: {el.text}" for el in snap.elements[:20]])
@@ -67,7 +67,7 @@
67
67
  "state": "uploaded",
68
68
  "size": 78091,
69
69
  "digest": "sha256:e281f8b755b61da4b8015d6172064aa9a337c14133ceceff4ab29199ee53307e",
70
- "download_count": 0,
70
+ "download_count": 5,
71
71
  "created_at": "2025-12-29T03:57:09Z",
72
72
  "updated_at": "2025-12-29T03:57:09Z",
73
73
  "browser_download_url": "https://github.com/SentienceAPI/Sentience-Geometry-Chrome-Extension/releases/download/v2.0.7/extension-files.tar.gz"
sentience/llm_provider.py CHANGED
@@ -263,6 +263,212 @@ class AnthropicProvider(LLMProvider):
263
263
  return self._model_name
264
264
 
265
265
 
266
+ class GLMProvider(LLMProvider):
267
+ """
268
+ Zhipu AI GLM provider implementation (GLM-4, GLM-4-Plus, etc.)
269
+
270
+ Requirements:
271
+ pip install zhipuai
272
+
273
+ Example:
274
+ >>> from sentience.llm_provider import GLMProvider
275
+ >>> llm = GLMProvider(api_key="your-api-key", model="glm-4-plus")
276
+ >>> response = llm.generate("You are a helpful assistant", "Hello!")
277
+ >>> print(response.content)
278
+ """
279
+
280
+ def __init__(self, api_key: str | None = None, model: str = "glm-4-plus"):
281
+ """
282
+ Initialize GLM provider
283
+
284
+ Args:
285
+ api_key: Zhipu AI API key (or set GLM_API_KEY env var)
286
+ model: Model name (glm-4-plus, glm-4, glm-4-air, glm-4-flash, etc.)
287
+ """
288
+ try:
289
+ from zhipuai import ZhipuAI
290
+ except ImportError:
291
+ raise ImportError("ZhipuAI package not installed. Install with: pip install zhipuai")
292
+
293
+ self.client = ZhipuAI(api_key=api_key)
294
+ self._model_name = model
295
+
296
+ def generate(
297
+ self,
298
+ system_prompt: str,
299
+ user_prompt: str,
300
+ temperature: float = 0.0,
301
+ max_tokens: int | None = None,
302
+ **kwargs,
303
+ ) -> LLMResponse:
304
+ """
305
+ Generate response using GLM API
306
+
307
+ Args:
308
+ system_prompt: System instruction
309
+ user_prompt: User query
310
+ temperature: Sampling temperature (0.0 = deterministic, 1.0 = creative)
311
+ max_tokens: Maximum tokens to generate
312
+ **kwargs: Additional GLM API parameters
313
+
314
+ Returns:
315
+ LLMResponse object
316
+ """
317
+ messages = []
318
+ if system_prompt:
319
+ messages.append({"role": "system", "content": system_prompt})
320
+ messages.append({"role": "user", "content": user_prompt})
321
+
322
+ # Build API parameters
323
+ api_params = {
324
+ "model": self._model_name,
325
+ "messages": messages,
326
+ "temperature": temperature,
327
+ }
328
+
329
+ if max_tokens:
330
+ api_params["max_tokens"] = max_tokens
331
+
332
+ # Merge additional parameters
333
+ api_params.update(kwargs)
334
+
335
+ # Call GLM API
336
+ response = self.client.chat.completions.create(**api_params)
337
+
338
+ choice = response.choices[0]
339
+ usage = response.usage
340
+
341
+ return LLMResponse(
342
+ content=choice.message.content,
343
+ prompt_tokens=usage.prompt_tokens if usage else None,
344
+ completion_tokens=usage.completion_tokens if usage else None,
345
+ total_tokens=usage.total_tokens if usage else None,
346
+ model_name=response.model,
347
+ finish_reason=choice.finish_reason,
348
+ )
349
+
350
+ def supports_json_mode(self) -> bool:
351
+ """GLM-4 models support JSON mode"""
352
+ return "glm-4" in self._model_name.lower()
353
+
354
+ @property
355
+ def model_name(self) -> str:
356
+ return self._model_name
357
+
358
+
359
+ class GeminiProvider(LLMProvider):
360
+ """
361
+ Google Gemini provider implementation (Gemini 2.0, Gemini 1.5 Pro, etc.)
362
+
363
+ Requirements:
364
+ pip install google-generativeai
365
+
366
+ Example:
367
+ >>> from sentience.llm_provider import GeminiProvider
368
+ >>> llm = GeminiProvider(api_key="your-api-key", model="gemini-2.0-flash-exp")
369
+ >>> response = llm.generate("You are a helpful assistant", "Hello!")
370
+ >>> print(response.content)
371
+ """
372
+
373
+ def __init__(self, api_key: str | None = None, model: str = "gemini-2.0-flash-exp"):
374
+ """
375
+ Initialize Gemini provider
376
+
377
+ Args:
378
+ api_key: Google API key (or set GEMINI_API_KEY or GOOGLE_API_KEY env var)
379
+ model: Model name (gemini-2.0-flash-exp, gemini-1.5-pro, gemini-1.5-flash, etc.)
380
+ """
381
+ try:
382
+ import google.generativeai as genai
383
+ except ImportError:
384
+ raise ImportError(
385
+ "Google Generative AI package not installed. Install with: pip install google-generativeai"
386
+ )
387
+
388
+ # Configure API key
389
+ if api_key:
390
+ genai.configure(api_key=api_key)
391
+ else:
392
+ import os
393
+
394
+ api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
395
+ if api_key:
396
+ genai.configure(api_key=api_key)
397
+
398
+ self.genai = genai
399
+ self._model_name = model
400
+ self.model = genai.GenerativeModel(model)
401
+
402
+ def generate(
403
+ self,
404
+ system_prompt: str,
405
+ user_prompt: str,
406
+ temperature: float = 0.0,
407
+ max_tokens: int | None = None,
408
+ **kwargs,
409
+ ) -> LLMResponse:
410
+ """
411
+ Generate response using Gemini API
412
+
413
+ Args:
414
+ system_prompt: System instruction
415
+ user_prompt: User query
416
+ temperature: Sampling temperature (0.0 = deterministic, 2.0 = very creative)
417
+ max_tokens: Maximum tokens to generate
418
+ **kwargs: Additional Gemini API parameters
419
+
420
+ Returns:
421
+ LLMResponse object
422
+ """
423
+ # Combine system and user prompts (Gemini doesn't have separate system role in all versions)
424
+ full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt
425
+
426
+ # Build generation config
427
+ generation_config = {
428
+ "temperature": temperature,
429
+ }
430
+
431
+ if max_tokens:
432
+ generation_config["max_output_tokens"] = max_tokens
433
+
434
+ # Merge additional parameters
435
+ generation_config.update(kwargs)
436
+
437
+ # Call Gemini API
438
+ response = self.model.generate_content(full_prompt, generation_config=generation_config)
439
+
440
+ # Extract content
441
+ content = response.text if response.text else ""
442
+
443
+ # Token usage (if available)
444
+ prompt_tokens = None
445
+ completion_tokens = None
446
+ total_tokens = None
447
+
448
+ if hasattr(response, "usage_metadata") and response.usage_metadata:
449
+ prompt_tokens = response.usage_metadata.prompt_token_count
450
+ completion_tokens = response.usage_metadata.candidates_token_count
451
+ total_tokens = response.usage_metadata.total_token_count
452
+
453
+ return LLMResponse(
454
+ content=content,
455
+ prompt_tokens=prompt_tokens,
456
+ completion_tokens=completion_tokens,
457
+ total_tokens=total_tokens,
458
+ model_name=self._model_name,
459
+ finish_reason=None, # Gemini uses different finish reason format
460
+ )
461
+
462
+ def supports_json_mode(self) -> bool:
463
+ """Gemini 1.5+ models support JSON mode via response_mime_type"""
464
+ model_lower = self._model_name.lower()
465
+ return any(x in model_lower for x in ["gemini-1.5", "gemini-2.0"])
466
+
467
+ @property
468
+ def model_name(self) -> str:
469
+ return self._model_name
470
+
471
+
266
472
  class LocalLLMProvider(LLMProvider):
267
473
  """
268
474
  Local LLM provider using HuggingFace Transformers
sentience/models.py CHANGED
@@ -2,7 +2,7 @@
2
2
  Pydantic models for Sentience SDK - matches spec/snapshot.schema.json
3
3
  """
4
4
 
5
- from typing import Literal
5
+ from typing import Literal, Optional
6
6
 
7
7
  from pydantic import BaseModel, Field
8
8
 
@@ -44,6 +44,12 @@ class Element(BaseModel):
44
44
  is_occluded: bool = False
45
45
  z_index: int = 0
46
46
 
47
+ # ML reranking metadata (optional - can be absent or null)
48
+ rerank_index: int | None = None # 0-based, The rank after ML reranking
49
+ heuristic_index: int | None = None # 0-based, Where it would have been without ML
50
+ ml_probability: float | None = None # Confidence score from ONNX model (0.0 - 1.0)
51
+ ml_score: float | None = None # Raw logit score (optional, for debugging)
52
+
47
53
 
48
54
  class Snapshot(BaseModel):
49
55
  """Snapshot response from extension"""