2025-10-01 23:29:25 +00:00
#!/usr/bin/env python3
"""
Vision Tools Module
This module provides vision analysis tools that work with image URLs .
2026-03-14 21:14:20 -07:00
Uses the centralized auxiliary vision router , which can select OpenRouter ,
Nous , Codex , native Anthropic , or a custom OpenAI - compatible endpoint .
2025-10-01 23:29:25 +00:00
Available tools :
- vision_analyze_tool : Analyze images from URLs with custom prompts
Features :
2025-10-08 02:38:04 +00:00
- Downloads images from URLs and converts to base64 for API compatibility
2025-10-01 23:29:25 +00:00
- Comprehensive image description
- Context - aware analysis based on user queries
2025-10-08 02:38:04 +00:00
- Automatic temporary file cleanup
2025-10-01 23:29:25 +00:00
- Proper error handling and validation
- Debug logging support
Usage :
from vision_tools import vision_analyze_tool
import asyncio
# Analyze an image
result = await vision_analyze_tool (
image_url = " https://example.com/image.jpg " ,
user_prompt = " What architectural style is this building? "
)
"""
2026-03-05 16:11:59 +03:00
import asyncio
import base64
2025-10-01 23:29:25 +00:00
import json
2026-02-21 03:11:11 -08:00
import logging
2025-10-01 23:29:25 +00:00
import os
import uuid
from pathlib import Path
2026-03-05 16:11:59 +03:00
from typing import Any , Awaitable , Dict , Optional
from urllib . parse import urlparse
2026-02-20 23:23:32 -08:00
import httpx
2026-03-11 20:52:19 -07:00
from agent . auxiliary_client import async_call_llm
2026-02-21 03:53:24 -08:00
from tools . debug_helpers import DebugSession
2025-10-01 23:29:25 +00:00
2026-02-21 03:11:11 -08:00
logger = logging . getLogger ( __name__ )
2026-02-21 03:53:24 -08:00
_debug = DebugSession ( " vision_tools " , env_var = " VISION_TOOLS_DEBUG " )
2025-10-01 23:29:25 +00:00
def _validate_image_url ( url : str ) - > bool :
"""
Basic validation of image URL format .
Args :
url ( str ) : The URL to validate
Returns :
bool : True if URL appears to be valid , False otherwise
"""
if not url or not isinstance ( url , str ) :
return False
2026-03-05 16:11:59 +03:00
# Basic HTTP/HTTPS URL check
if not ( url . startswith ( " http:// " ) or url . startswith ( " https:// " ) ) :
2025-10-01 23:29:25 +00:00
return False
2026-03-05 16:11:59 +03:00
# Parse to ensure we at least have a network location; still allow URLs
# without file extensions (e.g. CDN endpoints that redirect to images).
parsed = urlparse ( url )
if not parsed . netloc :
return False
return True # Allow all well-formed HTTP/HTTPS URLs for flexibility
2025-10-01 23:29:25 +00:00
2026-01-18 10:11:59 +00:00
async def _download_image ( image_url : str , destination : Path , max_retries : int = 3 ) - > Path :
2025-10-08 02:38:04 +00:00
"""
2026-01-18 10:11:59 +00:00
Download an image from a URL to a local destination ( async ) with retry logic .
2025-10-08 02:38:04 +00:00
Args :
image_url ( str ) : The URL of the image to download
destination ( Path ) : The path where the image should be saved
2026-01-18 10:11:59 +00:00
max_retries ( int ) : Maximum number of retry attempts ( default : 3 )
2025-10-08 02:38:04 +00:00
Returns :
Path : The path to the downloaded image
Raises :
2026-01-18 10:11:59 +00:00
Exception : If download fails after all retries
2025-10-08 02:38:04 +00:00
"""
2026-01-18 10:11:59 +00:00
import asyncio
2025-10-08 02:38:04 +00:00
# Create parent directories if they don't exist
destination . parent . mkdir ( parents = True , exist_ok = True )
2026-01-18 10:11:59 +00:00
last_error = None
for attempt in range ( max_retries ) :
try :
# Download the image with appropriate headers using async httpx
2026-01-29 06:10:24 +00:00
# Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum)
async with httpx . AsyncClient ( timeout = 30.0 , follow_redirects = True ) as client :
2026-01-18 10:11:59 +00:00
response = await client . get (
image_url ,
2026-01-29 06:10:24 +00:00
headers = {
" User-Agent " : " Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 " ,
" Accept " : " image/*,*/*;q=0.8 " ,
} ,
2026-01-18 10:11:59 +00:00
)
response . raise_for_status ( )
# Save the image content
destination . write_bytes ( response . content )
return destination
except Exception as e :
last_error = e
if attempt < max_retries - 1 :
wait_time = 2 * * ( attempt + 1 ) # 2s, 4s, 8s
2026-02-21 03:11:11 -08:00
logger . warning ( " Image download failed (attempt %s / %s ): %s " , attempt + 1 , max_retries , str ( e ) [ : 50 ] )
logger . warning ( " Retrying in %s s... " , wait_time )
2026-01-18 10:11:59 +00:00
await asyncio . sleep ( wait_time )
else :
2026-03-05 16:11:59 +03:00
logger . error (
" Image download failed after %s attempts: %s " ,
max_retries ,
str ( e ) [ : 100 ] ,
exc_info = True ,
)
2025-10-08 02:38:04 +00:00
2026-01-18 10:11:59 +00:00
raise last_error
2025-10-08 02:38:04 +00:00
def _determine_mime_type ( image_path : Path ) - > str :
"""
Determine the MIME type of an image based on its file extension .
Args :
image_path ( Path ) : Path to the image file
Returns :
str : The MIME type ( defaults to image / jpeg if unknown )
"""
extension = image_path . suffix . lower ( )
mime_types = {
' .jpg ' : ' image/jpeg ' ,
' .jpeg ' : ' image/jpeg ' ,
' .png ' : ' image/png ' ,
' .gif ' : ' image/gif ' ,
' .bmp ' : ' image/bmp ' ,
' .webp ' : ' image/webp ' ,
' .svg ' : ' image/svg+xml '
}
return mime_types . get ( extension , ' image/jpeg ' )
def _image_to_base64_data_url ( image_path : Path , mime_type : Optional [ str ] = None ) - > str :
"""
Convert an image file to a base64 - encoded data URL .
Args :
image_path ( Path ) : Path to the image file
mime_type ( Optional [ str ] ) : MIME type of the image ( auto - detected if None )
Returns :
str : Base64 - encoded data URL ( e . g . , " data:image/jpeg;base64,... " )
"""
# Read the image as bytes
data = image_path . read_bytes ( )
# Encode to base64
encoded = base64 . b64encode ( data ) . decode ( " ascii " )
# Determine MIME type
mime = mime_type or _determine_mime_type ( image_path )
# Create data URL
data_url = f " data: { mime } ;base64, { encoded } "
return data_url
2025-10-01 23:29:25 +00:00
async def vision_analyze_tool (
image_url : str ,
user_prompt : str ,
2026-03-11 20:52:19 -07:00
model : str = None ,
2025-10-01 23:29:25 +00:00
) - > str :
"""
2026-02-15 16:10:50 -08:00
Analyze an image from a URL or local file path using vision AI .
2025-10-01 23:29:25 +00:00
2026-02-15 16:10:50 -08:00
This tool accepts either an HTTP / HTTPS URL or a local file path . For URLs ,
it downloads the image first . In both cases , the image is converted to base64
and processed using Gemini 3 Flash Preview via OpenRouter API .
2025-10-08 02:38:04 +00:00
2025-10-01 23:29:25 +00:00
The user_prompt parameter is expected to be pre - formatted by the calling
function ( typically model_tools . py ) to include both full description
requests and specific questions .
Args :
2026-02-15 16:10:50 -08:00
image_url ( str ) : The URL or local file path of the image to analyze .
Accepts http : / / , https : / / URLs or absolute / relative file paths .
2025-10-01 23:29:25 +00:00
user_prompt ( str ) : The pre - formatted prompt for the vision model
2026-01-14 13:40:10 +00:00
model ( str ) : The vision model to use ( default : google / gemini - 3 - flash - preview )
2025-10-01 23:29:25 +00:00
Returns :
str : JSON string containing the analysis results with the following structure :
{
" success " : bool ,
" analysis " : str ( defaults to error message if None )
}
Raises :
2025-10-08 02:38:04 +00:00
Exception : If download fails , analysis fails , or API key is not set
Note :
2026-02-15 16:10:50 -08:00
- For URLs , temporary images are stored in . / temp_vision_images / and cleaned up
- For local file paths , the file is used directly and NOT deleted
2025-10-08 02:38:04 +00:00
- Supports common image formats ( JPEG , PNG , GIF , WebP , etc . )
2025-10-01 23:29:25 +00:00
"""
debug_call_data = {
" parameters " : {
" image_url " : image_url ,
2025-10-15 18:07:06 +00:00
" user_prompt " : user_prompt [ : 200 ] + " ... " if len ( user_prompt ) > 200 else user_prompt ,
2025-10-01 23:29:25 +00:00
" model " : model
} ,
" error " : None ,
" success " : False ,
" analysis_length " : 0 ,
2025-10-15 18:07:06 +00:00
" model_used " : model ,
" image_size_bytes " : 0
2025-10-01 23:29:25 +00:00
}
2025-10-08 02:38:04 +00:00
temp_image_path = None
2026-02-15 16:10:50 -08:00
# Track whether we should clean up the file after processing.
# Local files (e.g. from the image cache) should NOT be deleted.
should_cleanup = True
2025-10-08 02:38:04 +00:00
2025-10-01 23:29:25 +00:00
try :
2026-02-23 02:11:33 -08:00
from tools . interrupt import is_interrupted
if is_interrupted ( ) :
return json . dumps ( { " success " : False , " error " : " Interrupted " } )
2026-02-21 03:11:11 -08:00
logger . info ( " Analyzing image: %s " , image_url [ : 60 ] )
logger . info ( " User prompt: %s " , user_prompt [ : 100 ] )
2025-10-01 23:29:25 +00:00
2026-02-15 16:10:50 -08:00
# Determine if this is a local file path or a remote URL
local_path = Path ( image_url )
if local_path . is_file ( ) :
# Local file path (e.g. from platform image cache) -- skip download
2026-02-21 03:11:11 -08:00
logger . info ( " Using local image file: %s " , image_url )
2026-02-15 16:10:50 -08:00
temp_image_path = local_path
should_cleanup = False # Don't delete cached/local files
elif _validate_image_url ( image_url ) :
# Remote URL -- download to a temporary location
2026-02-21 03:11:11 -08:00
logger . info ( " Downloading image from URL... " )
2026-02-15 16:10:50 -08:00
temp_dir = Path ( " ./temp_vision_images " )
temp_image_path = temp_dir / f " temp_image_ { uuid . uuid4 ( ) } .jpg "
await _download_image ( image_url , temp_image_path )
should_cleanup = True
else :
raise ValueError (
" Invalid image source. Provide an HTTP/HTTPS URL or a valid local file path. "
)
2025-10-15 18:07:06 +00:00
# Get image file size for logging
image_size_bytes = temp_image_path . stat ( ) . st_size
image_size_kb = image_size_bytes / 1024
2026-02-21 03:11:11 -08:00
logger . info ( " Image ready ( %.1f KB) " , image_size_kb )
2025-10-08 02:38:04 +00:00
# Convert image to base64 data URL
2026-02-21 03:11:11 -08:00
logger . info ( " Converting image to base64... " )
2025-10-08 02:38:04 +00:00
image_data_url = _image_to_base64_data_url ( temp_image_path )
2025-10-15 18:07:06 +00:00
# Calculate size in KB for better readability
data_size_kb = len ( image_data_url ) / 1024
2026-02-21 03:11:11 -08:00
logger . info ( " Image converted to base64 ( %.1f KB) " , data_size_kb )
2025-10-15 18:07:06 +00:00
debug_call_data [ " image_size_bytes " ] = image_size_bytes
2025-10-08 02:38:04 +00:00
2025-10-01 23:29:25 +00:00
# Use the prompt as provided (model_tools.py now handles full description formatting)
comprehensive_prompt = user_prompt
2025-10-08 02:38:04 +00:00
# Prepare the message with base64-encoded image
2025-10-01 23:29:25 +00:00
messages = [
{
" role " : " user " ,
" content " : [
{
" type " : " text " ,
" text " : comprehensive_prompt
} ,
{
" type " : " image_url " ,
" image_url " : {
2025-10-08 02:38:04 +00:00
" url " : image_data_url
2025-10-01 23:29:25 +00:00
}
}
]
}
]
2026-03-11 20:52:19 -07:00
logger . info ( " Processing image with vision model... " )
2025-10-01 23:29:25 +00:00
2026-03-22 05:28:24 -07:00
# Call the vision API via centralized router.
# Read timeout from config.yaml (auxiliary.vision.timeout), default 30s.
vision_timeout = 30.0
try :
from hermes_cli . config import load_config
_cfg = load_config ( )
_vt = _cfg . get ( " auxiliary " , { } ) . get ( " vision " , { } ) . get ( " timeout " )
if _vt is not None :
vision_timeout = float ( _vt )
except Exception :
pass
2026-03-11 20:52:19 -07:00
call_kwargs = {
" task " : " vision " ,
" messages " : messages ,
" temperature " : 0.1 ,
" max_tokens " : 2000 ,
2026-03-22 05:28:24 -07:00
" timeout " : vision_timeout ,
2026-03-11 20:52:19 -07:00
}
if model :
call_kwargs [ " model " ] = model
response = await async_call_llm ( * * call_kwargs )
2025-10-01 23:29:25 +00:00
# Extract the analysis
analysis = response . choices [ 0 ] . message . content . strip ( )
analysis_length = len ( analysis )
2026-02-21 03:11:11 -08:00
logger . info ( " Image analysis completed ( %s characters) " , analysis_length )
2025-10-01 23:29:25 +00:00
# Prepare successful response
result = {
" success " : True ,
" analysis " : analysis or " There was a problem with the request and the image could not be analyzed. "
}
debug_call_data [ " success " ] = True
debug_call_data [ " analysis_length " ] = analysis_length
# Log debug information
2026-02-21 03:53:24 -08:00
_debug . log_call ( " vision_analyze_tool " , debug_call_data )
_debug . save ( )
2025-10-01 23:29:25 +00:00
2025-11-05 03:47:17 +00:00
return json . dumps ( result , indent = 2 , ensure_ascii = False )
2025-10-01 23:29:25 +00:00
except Exception as e :
error_msg = f " Error analyzing image: { str ( e ) } "
2026-03-05 16:11:59 +03:00
logger . error ( " %s " , error_msg , exc_info = True )
2025-10-01 23:29:25 +00:00
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
# Detect vision capability errors — give the model a clear message
# so it can inform the user instead of a cryptic API error.
err_str = str ( e ) . lower ( )
if any ( hint in err_str for hint in (
" does not support " , " not support image " , " invalid_request " ,
" content_policy " , " image_url " , " multimodal " ,
" unrecognized request argument " , " image input " ,
) ) :
analysis = (
f " { model } does not support vision or our request was not "
f " accepted by the server. Error: { e } "
)
else :
analysis = (
" There was a problem with the request and the image could not "
f " be analyzed. Error: { e } "
)
2025-10-01 23:29:25 +00:00
# Prepare error response
result = {
" success " : False ,
2026-03-12 13:25:09 +01:00
" error " : error_msg ,
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
" analysis " : analysis ,
2025-10-01 23:29:25 +00:00
}
debug_call_data [ " error " ] = error_msg
2026-02-21 03:53:24 -08:00
_debug . log_call ( " vision_analyze_tool " , debug_call_data )
_debug . save ( )
2025-10-01 23:29:25 +00:00
2025-11-05 03:47:17 +00:00
return json . dumps ( result , indent = 2 , ensure_ascii = False )
2025-10-08 02:38:04 +00:00
finally :
2026-02-15 16:10:50 -08:00
# Clean up temporary image file (but NOT local/cached files)
if should_cleanup and temp_image_path and temp_image_path . exists ( ) :
2025-10-08 02:38:04 +00:00
try :
temp_image_path . unlink ( )
2026-02-21 03:11:11 -08:00
logger . debug ( " Cleaned up temporary image file " )
2025-10-08 02:38:04 +00:00
except Exception as cleanup_error :
2026-03-05 16:11:59 +03:00
logger . warning (
" Could not delete temporary file: %s " , cleanup_error , exc_info = True
)
2025-10-01 23:29:25 +00:00
def check_vision_requirements ( ) - > bool :
2026-03-14 20:22:13 -07:00
""" Check if the configured runtime vision path can resolve a client. """
2026-03-11 20:52:19 -07:00
try :
2026-03-14 20:22:13 -07:00
from agent . auxiliary_client import resolve_vision_provider_client
_provider , client , _model = resolve_vision_provider_client ( )
2026-03-11 20:52:19 -07:00
return client is not None
except Exception :
return False
2025-10-01 23:29:25 +00:00
def get_debug_session_info ( ) - > Dict [ str , Any ] :
"""
Get information about the current debug session .
Returns :
Dict [ str , Any ] : Dictionary containing debug session information
"""
2026-02-21 03:53:24 -08:00
return _debug . get_session_info ( )
2025-10-01 23:29:25 +00:00
if __name__ == " __main__ " :
"""
Simple test / demo when run directly
"""
print ( " 👁️ Vision Tools Module " )
print ( " = " * 40 )
2026-02-22 02:16:11 -08:00
# Check if vision model is available
api_available = check_vision_requirements ( )
2025-10-01 23:29:25 +00:00
if not api_available :
2026-02-22 02:16:11 -08:00
print ( " ❌ No auxiliary vision model available " )
2026-03-14 21:14:20 -07:00
print ( " Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint). " )
2025-10-01 23:29:25 +00:00
exit ( 1 )
else :
2026-03-11 20:52:19 -07:00
print ( " ✅ Vision model available " )
2025-10-01 23:29:25 +00:00
print ( " 🛠️ Vision tools ready for use! " )
# Show debug mode status
2026-02-21 03:53:24 -08:00
if _debug . active :
print ( f " 🐛 Debug mode ENABLED - Session ID: { _debug . session_id } " )
print ( f " Debug logs will be saved to: ./logs/vision_tools_debug_ { _debug . session_id } .json " )
2025-10-01 23:29:25 +00:00
else :
print ( " 🐛 Debug mode disabled (set VISION_TOOLS_DEBUG=true to enable) " )
print ( " \n Basic usage: " )
print ( " from vision_tools import vision_analyze_tool " )
print ( " import asyncio " )
print ( " " )
print ( " async def main(): " )
print ( " result = await vision_analyze_tool( " )
print ( " image_url= ' https://example.com/image.jpg ' , " )
print ( " user_prompt= ' What do you see in this image? ' " )
print ( " ) " )
print ( " print(result) " )
print ( " asyncio.run(main()) " )
print ( " \n Example prompts: " )
print ( " - ' What architectural style is this building? ' " )
print ( " - ' Describe the emotions and mood in this image ' " )
print ( " - ' What text can you read in this image? ' " )
print ( " - ' Identify any safety hazards visible ' " )
print ( " - ' What products or brands are shown? ' " )
print ( " \n Debug mode: " )
print ( " # Enable debug logging " )
print ( " export VISION_TOOLS_DEBUG=true " )
print ( " # Debug logs capture all vision analysis calls and results " )
print ( " # Logs saved to: ./logs/vision_tools_debug_UUID.json " )
2026-02-21 20:22:33 -08:00
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
from tools . registry import registry
VISION_ANALYZE_SCHEMA = {
" name " : " vision_analyze " ,
" description " : " Analyze images using AI vision. Provides a comprehensive description and answers a specific question about the image content. " ,
" parameters " : {
" type " : " object " ,
" properties " : {
" image_url " : {
" type " : " string " ,
" description " : " Image URL (http/https) or local file path to analyze. "
} ,
" question " : {
" type " : " string " ,
" description " : " Your specific question or request about the image to resolve. The AI will automatically provide a complete image description AND answer your specific question. "
}
} ,
" required " : [ " image_url " , " question " ]
}
}
2026-03-05 16:11:59 +03:00
def _handle_vision_analyze ( args : Dict [ str , Any ] , * * kw : Any ) - > Awaitable [ str ] :
2026-02-21 20:22:33 -08:00
image_url = args . get ( " image_url " , " " )
question = args . get ( " question " , " " )
2026-03-05 16:11:59 +03:00
full_prompt = (
" Fully describe and explain everything about this image, then answer the "
f " following question: \n \n { question } "
)
2026-03-11 20:52:19 -07:00
model = os . getenv ( " AUXILIARY_VISION_MODEL " , " " ) . strip ( ) or None
2026-02-22 02:16:11 -08:00
return vision_analyze_tool ( image_url , full_prompt , model )
2026-02-21 20:22:33 -08:00
registry . register (
name = " vision_analyze " ,
toolset = " vision " ,
schema = VISION_ANALYZE_SCHEMA ,
handler = _handle_vision_analyze ,
check_fn = check_vision_requirements ,
is_async = True ,
2026-03-15 20:21:21 -07:00
emoji = " 👁️ " ,
2026-02-21 20:22:33 -08:00
)