viscribe 1.0.1__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.
- viscribe/__init__.py +4 -0
- viscribe/async_client.py +316 -0
- viscribe/client.py +327 -0
- viscribe/config.py +6 -0
- viscribe/exceptions.py +7 -0
- viscribe/logger.py +119 -0
- viscribe/models/__init__.py +16 -0
- viscribe/models/image.py +188 -0
- viscribe/utils/__init__.py +0 -0
- viscribe/utils/helpers.py +80 -0
- viscribe-1.0.1.dist-info/METADATA +235 -0
- viscribe-1.0.1.dist-info/RECORD +14 -0
- viscribe-1.0.1.dist-info/WHEEL +4 -0
- viscribe-1.0.1.dist-info/licenses/LICENSE +21 -0
viscribe/__init__.py
ADDED
viscribe/async_client.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Optional, Type, Union
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientSession, ClientTimeout, TCPConnector
|
|
5
|
+
from aiohttp.client_exceptions import ClientError
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from viscribe.config import API_BASE_URL, DEFAULT_HEADERS
|
|
9
|
+
from viscribe.exceptions import APIError
|
|
10
|
+
from viscribe.logger import viscribe_logger as logger
|
|
11
|
+
from viscribe.models.image import (
|
|
12
|
+
CreditsResponse,
|
|
13
|
+
ExtractField,
|
|
14
|
+
FeedbackRequest,
|
|
15
|
+
FeedbackResponse,
|
|
16
|
+
ImageAskRequest,
|
|
17
|
+
ImageAskResponse,
|
|
18
|
+
ImageClassifyRequest,
|
|
19
|
+
ImageClassifyResponse,
|
|
20
|
+
ImageCompareRequest,
|
|
21
|
+
ImageCompareResponse,
|
|
22
|
+
ImageDescribeRequest,
|
|
23
|
+
ImageDescribeResponse,
|
|
24
|
+
ImageExtractRequest,
|
|
25
|
+
ImageExtractResponse,
|
|
26
|
+
)
|
|
27
|
+
from viscribe.utils.helpers import handle_async_response, validate_api_key
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncClient:
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_env(
|
|
33
|
+
cls,
|
|
34
|
+
verify_ssl: bool = True,
|
|
35
|
+
timeout: Optional[float] = None,
|
|
36
|
+
max_retries: int = 3,
|
|
37
|
+
retry_delay: float = 1.0,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize AsyncClient using API key from environment variable.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
verify_ssl: Whether to verify SSL certificates
|
|
43
|
+
timeout: Request timeout in seconds. None means no timeout (infinite)
|
|
44
|
+
max_retries: Maximum number of retry attempts
|
|
45
|
+
retry_delay: Delay between retries in seconds
|
|
46
|
+
"""
|
|
47
|
+
from os import getenv
|
|
48
|
+
|
|
49
|
+
api_key = getenv("VISCRIBE_API_KEY")
|
|
50
|
+
if not api_key:
|
|
51
|
+
raise ValueError("VISCRIBE_API_KEY environment variable not set")
|
|
52
|
+
return cls(
|
|
53
|
+
api_key=api_key,
|
|
54
|
+
verify_ssl=verify_ssl,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
max_retries=max_retries,
|
|
57
|
+
retry_delay=retry_delay,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: str = None,
|
|
63
|
+
verify_ssl: bool = True,
|
|
64
|
+
timeout: Optional[float] = None,
|
|
65
|
+
max_retries: int = 3,
|
|
66
|
+
retry_delay: float = 1.0,
|
|
67
|
+
):
|
|
68
|
+
"""Initialize AsyncClient with configurable parameters.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
api_key: API key for authentication. If None, will try to load from environment
|
|
72
|
+
verify_ssl: Whether to verify SSL certificates
|
|
73
|
+
timeout: Request timeout in seconds. None means no timeout (infinite)
|
|
74
|
+
max_retries: Maximum number of retry attempts
|
|
75
|
+
retry_delay: Delay between retries in seconds
|
|
76
|
+
"""
|
|
77
|
+
logger.info("🔑 Initializing AsyncClient")
|
|
78
|
+
|
|
79
|
+
# Try to get API key from environment if not provided
|
|
80
|
+
if api_key is None:
|
|
81
|
+
from os import getenv
|
|
82
|
+
|
|
83
|
+
api_key = getenv("VISCRIBE_API_KEY")
|
|
84
|
+
if not api_key:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"VISCRIBE_API_KEY not provided and not found in environment"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
validate_api_key(api_key)
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"🛠️ Configuration: verify_ssl={verify_ssl}, timeout={timeout}, max_retries={max_retries}"
|
|
92
|
+
)
|
|
93
|
+
self.api_key = api_key
|
|
94
|
+
self.headers = {**DEFAULT_HEADERS, "VISCRIBE-APIKEY": api_key}
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
self.retry_delay = retry_delay
|
|
97
|
+
|
|
98
|
+
ssl = None if verify_ssl else False
|
|
99
|
+
self.timeout = ClientTimeout(total=timeout) if timeout is not None else None
|
|
100
|
+
|
|
101
|
+
self.session = ClientSession(
|
|
102
|
+
headers=self.headers, connector=TCPConnector(ssl=ssl), timeout=self.timeout
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info("✅ AsyncClient initialized successfully")
|
|
106
|
+
|
|
107
|
+
async def _make_request(self, method: str, url: str, **kwargs) -> Any:
|
|
108
|
+
"""Make HTTP request with retry logic."""
|
|
109
|
+
for attempt in range(self.max_retries):
|
|
110
|
+
try:
|
|
111
|
+
logger.info(
|
|
112
|
+
f"🚀 Making {method} request to {url} (Attempt {attempt + 1}/{self.max_retries})"
|
|
113
|
+
)
|
|
114
|
+
logger.debug(f"🔍 Request parameters: {kwargs}")
|
|
115
|
+
|
|
116
|
+
async with self.session.request(method, url, **kwargs) as response:
|
|
117
|
+
logger.debug(f"📥 Response status: {response.status}")
|
|
118
|
+
result = await handle_async_response(response)
|
|
119
|
+
logger.info(f"✅ Request completed successfully: {method} {url}")
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
except ClientError as e:
|
|
123
|
+
logger.warning(f"⚠️ Request attempt {attempt + 1} failed: {str(e)}")
|
|
124
|
+
if hasattr(e, "status") and e.status is not None:
|
|
125
|
+
try:
|
|
126
|
+
error_data = await e.response.json()
|
|
127
|
+
error_msg = error_data.get("error", str(e))
|
|
128
|
+
logger.error(f"🔴 API Error: {error_msg}")
|
|
129
|
+
raise APIError(error_msg, status_code=e.status)
|
|
130
|
+
except ValueError:
|
|
131
|
+
logger.error("🔴 Could not parse error response")
|
|
132
|
+
raise APIError(
|
|
133
|
+
str(e),
|
|
134
|
+
status_code=e.status if hasattr(e, "status") else None,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if attempt == self.max_retries - 1:
|
|
138
|
+
logger.error(f"❌ All retry attempts failed for {method} {url}")
|
|
139
|
+
raise ConnectionError(f"Failed to connect to API: {str(e)}")
|
|
140
|
+
|
|
141
|
+
retry_delay = self.retry_delay * (attempt + 1)
|
|
142
|
+
logger.info(f"⏳ Waiting {retry_delay}s before retry {attempt + 2}")
|
|
143
|
+
await asyncio.sleep(retry_delay)
|
|
144
|
+
|
|
145
|
+
async def submit_feedback(self, request_id: str, rating: int, feedback_text: Optional[str] = None) -> FeedbackResponse:
|
|
146
|
+
"""Submit feedback for a request"""
|
|
147
|
+
logger.info(f"📝 Submitting feedback for request {request_id}")
|
|
148
|
+
feedback = FeedbackRequest(
|
|
149
|
+
request_id=request_id, rating=rating, feedback_text=feedback_text
|
|
150
|
+
)
|
|
151
|
+
payload = feedback.model_dump(exclude_none=True)
|
|
152
|
+
result = await self._make_request("POST", f"{API_BASE_URL}/feedback", json=payload)
|
|
153
|
+
return FeedbackResponse(**result)
|
|
154
|
+
|
|
155
|
+
async def get_credits(self) -> CreditsResponse:
|
|
156
|
+
"""Get credits information"""
|
|
157
|
+
logger.info("💳 Fetching credits information")
|
|
158
|
+
result = await self._make_request("GET", f"{API_BASE_URL}/credits")
|
|
159
|
+
return CreditsResponse(**result)
|
|
160
|
+
|
|
161
|
+
async def close(self):
|
|
162
|
+
"""Close the session to free up resources"""
|
|
163
|
+
logger.info("🔒 Closing AsyncClient session")
|
|
164
|
+
await self.session.close()
|
|
165
|
+
logger.debug("✅ Session closed successfully")
|
|
166
|
+
|
|
167
|
+
async def __aenter__(self):
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
171
|
+
await self.close()
|
|
172
|
+
|
|
173
|
+
async def describe_image(
|
|
174
|
+
self,
|
|
175
|
+
image_url: str = None,
|
|
176
|
+
image_base64: str = None,
|
|
177
|
+
instruction: str = None,
|
|
178
|
+
generate_tags: bool = True,
|
|
179
|
+
) -> ImageDescribeResponse:
|
|
180
|
+
"""Send a describe image request with explicit arguments and validate with Pydantic."""
|
|
181
|
+
logger.info("🔍 Starting image describe request")
|
|
182
|
+
|
|
183
|
+
req = ImageDescribeRequest(
|
|
184
|
+
image_url=image_url,
|
|
185
|
+
image_base64=image_base64,
|
|
186
|
+
instruction=instruction,
|
|
187
|
+
generate_tags=generate_tags,
|
|
188
|
+
)
|
|
189
|
+
payload = req.model_dump(exclude_none=True)
|
|
190
|
+
result = await self._make_request(
|
|
191
|
+
"POST", f"{API_BASE_URL}/images/describe", json=payload
|
|
192
|
+
)
|
|
193
|
+
return ImageDescribeResponse(**result)
|
|
194
|
+
|
|
195
|
+
async def extract_image(
|
|
196
|
+
self,
|
|
197
|
+
image_url: str = None,
|
|
198
|
+
image_base64: str = None,
|
|
199
|
+
fields: list = None,
|
|
200
|
+
advanced_schema: Union[dict, BaseModel, Type[BaseModel]] = None,
|
|
201
|
+
instruction: str = None,
|
|
202
|
+
) -> ImageExtractResponse:
|
|
203
|
+
"""Send an extract structured data from image request with explicit arguments and validate with Pydantic.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
image_url: URL of the image
|
|
207
|
+
image_base64: Base64 encoded string of the image
|
|
208
|
+
fields: List of dictionaries with 'name', 'type', and optional 'description' keys.
|
|
209
|
+
Each field type can be 'text', 'number', 'array_text' (max 5), or 'array_number' (max 5).
|
|
210
|
+
Max 10 fields allowed.
|
|
211
|
+
advanced_schema: Full JSON schema for complex extraction (dict), a Pydantic BaseModel instance,
|
|
212
|
+
or a Pydantic BaseModel class. If a BaseModel is provided, it will be serialized
|
|
213
|
+
to its JSON schema. Use this for nested structures.
|
|
214
|
+
instruction: Optional instruction to guide the extraction process.
|
|
215
|
+
|
|
216
|
+
Note: Either fields or advanced_schema must be provided, not both.
|
|
217
|
+
"""
|
|
218
|
+
logger.info("🔍 Starting image extract request")
|
|
219
|
+
|
|
220
|
+
# Convert dictionaries to ExtractField models if fields are provided
|
|
221
|
+
validated_fields = None
|
|
222
|
+
if fields is not None:
|
|
223
|
+
validated_fields = [ExtractField(**field) if isinstance(field, dict) else field for field in fields]
|
|
224
|
+
|
|
225
|
+
# Convert Pydantic BaseModel to JSON schema if advanced_schema is a BaseModel
|
|
226
|
+
validated_schema = advanced_schema
|
|
227
|
+
if advanced_schema is not None:
|
|
228
|
+
if isinstance(advanced_schema, BaseModel):
|
|
229
|
+
# BaseModel instance
|
|
230
|
+
validated_schema = advanced_schema.model_json_schema()
|
|
231
|
+
elif isinstance(advanced_schema, type) and issubclass(advanced_schema, BaseModel):
|
|
232
|
+
# BaseModel class
|
|
233
|
+
validated_schema = advanced_schema.model_json_schema()
|
|
234
|
+
|
|
235
|
+
req = ImageExtractRequest(
|
|
236
|
+
image_url=image_url,
|
|
237
|
+
image_base64=image_base64,
|
|
238
|
+
fields=validated_fields,
|
|
239
|
+
advanced_schema=validated_schema,
|
|
240
|
+
instruction=instruction,
|
|
241
|
+
)
|
|
242
|
+
payload = req.model_dump(exclude_none=True)
|
|
243
|
+
result = await self._make_request(
|
|
244
|
+
"POST", f"{API_BASE_URL}/images/extract", json=payload
|
|
245
|
+
)
|
|
246
|
+
return ImageExtractResponse(**result)
|
|
247
|
+
|
|
248
|
+
async def classify_image(
|
|
249
|
+
self,
|
|
250
|
+
image_url: str = None,
|
|
251
|
+
image_base64: str = None,
|
|
252
|
+
classes: list = None,
|
|
253
|
+
class_descriptions: dict = None,
|
|
254
|
+
instruction: str = None,
|
|
255
|
+
multi_label: bool = False,
|
|
256
|
+
) -> ImageClassifyResponse:
|
|
257
|
+
"""Send an image classify request with explicit arguments and validate with Pydantic."""
|
|
258
|
+
logger.info("🔍 Starting image classify request")
|
|
259
|
+
|
|
260
|
+
req = ImageClassifyRequest(
|
|
261
|
+
image_url=image_url,
|
|
262
|
+
image_base64=image_base64,
|
|
263
|
+
classes=classes,
|
|
264
|
+
class_descriptions=class_descriptions,
|
|
265
|
+
instruction=instruction,
|
|
266
|
+
multi_label=multi_label,
|
|
267
|
+
)
|
|
268
|
+
payload = req.model_dump(exclude_none=True)
|
|
269
|
+
result = await self._make_request(
|
|
270
|
+
"POST", f"{API_BASE_URL}/images/classify", json=payload
|
|
271
|
+
)
|
|
272
|
+
return ImageClassifyResponse(**result)
|
|
273
|
+
|
|
274
|
+
async def ask_image(
|
|
275
|
+
self,
|
|
276
|
+
image_url: str = None,
|
|
277
|
+
image_base64: str = None,
|
|
278
|
+
question: str = None,
|
|
279
|
+
) -> ImageAskResponse:
|
|
280
|
+
"""Send an image VQA (ask) request with explicit arguments and validate with Pydantic."""
|
|
281
|
+
logger.info("🔍 Starting image ask (VQA) request")
|
|
282
|
+
|
|
283
|
+
req = ImageAskRequest(
|
|
284
|
+
image_url=image_url,
|
|
285
|
+
image_base64=image_base64,
|
|
286
|
+
question=question,
|
|
287
|
+
)
|
|
288
|
+
payload = req.model_dump(exclude_none=True)
|
|
289
|
+
result = await self._make_request("POST", f"{API_BASE_URL}/images/ask", json=payload)
|
|
290
|
+
return ImageAskResponse(**result)
|
|
291
|
+
|
|
292
|
+
async def compare_images(
|
|
293
|
+
self,
|
|
294
|
+
image1_url: str = None,
|
|
295
|
+
image1_base64: str = None,
|
|
296
|
+
image2_url: str = None,
|
|
297
|
+
image2_base64: str = None,
|
|
298
|
+
instruction: str = None,
|
|
299
|
+
) -> ImageCompareResponse:
|
|
300
|
+
"""Send an image compare request with explicit arguments and validate with Pydantic."""
|
|
301
|
+
logger.info("🔍 Starting image compare request")
|
|
302
|
+
|
|
303
|
+
req = ImageCompareRequest(
|
|
304
|
+
image1_url=image1_url,
|
|
305
|
+
image1_base64=image1_base64,
|
|
306
|
+
image2_url=image2_url,
|
|
307
|
+
image2_base64=image2_base64,
|
|
308
|
+
instruction=instruction,
|
|
309
|
+
)
|
|
310
|
+
payload = req.model_dump(exclude_none=True)
|
|
311
|
+
result = await self._make_request(
|
|
312
|
+
"POST", f"{API_BASE_URL}/images/compare", json=payload
|
|
313
|
+
)
|
|
314
|
+
return ImageCompareResponse(**result)
|
|
315
|
+
|
|
316
|
+
|
viscribe/client.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# Client implementation goes here
|
|
2
|
+
from typing import Any, Optional, Type, Union
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import urllib3
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from requests.exceptions import RequestException
|
|
8
|
+
|
|
9
|
+
from viscribe.config import API_BASE_URL, DEFAULT_HEADERS
|
|
10
|
+
from viscribe.exceptions import APIError
|
|
11
|
+
from viscribe.logger import viscribe_logger as logger
|
|
12
|
+
from viscribe.models.image import CreditsResponse, FeedbackRequest, FeedbackResponse
|
|
13
|
+
from viscribe.models.image import (
|
|
14
|
+
ExtractField,
|
|
15
|
+
ImageDescribeRequest,
|
|
16
|
+
ImageDescribeResponse,
|
|
17
|
+
ImageExtractRequest,
|
|
18
|
+
ImageExtractResponse,
|
|
19
|
+
ImageClassifyRequest,
|
|
20
|
+
ImageClassifyResponse,
|
|
21
|
+
ImageAskRequest,
|
|
22
|
+
ImageAskResponse,
|
|
23
|
+
ImageCompareRequest, ImageCompareResponse
|
|
24
|
+
)
|
|
25
|
+
from viscribe.utils.helpers import handle_sync_response, validate_api_key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Client:
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(
|
|
31
|
+
cls,
|
|
32
|
+
verify_ssl: bool = True,
|
|
33
|
+
timeout: Optional[float] = None,
|
|
34
|
+
max_retries: int = 3,
|
|
35
|
+
retry_delay: float = 1.0,
|
|
36
|
+
):
|
|
37
|
+
"""Initialize Client using API key from environment variable.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
verify_ssl: Whether to verify SSL certificates
|
|
41
|
+
timeout: Request timeout in seconds. None means no timeout (infinite)
|
|
42
|
+
max_retries: Maximum number of retry attempts
|
|
43
|
+
retry_delay: Delay between retries in seconds
|
|
44
|
+
"""
|
|
45
|
+
from os import getenv
|
|
46
|
+
|
|
47
|
+
api_key = getenv("VISCRIBE_API_KEY")
|
|
48
|
+
if not api_key:
|
|
49
|
+
raise ValueError("VISCRIBE_API_KEY environment variable not set")
|
|
50
|
+
return cls(
|
|
51
|
+
api_key=api_key,
|
|
52
|
+
verify_ssl=verify_ssl,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
max_retries=max_retries,
|
|
55
|
+
retry_delay=retry_delay,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str = None,
|
|
61
|
+
verify_ssl: bool = True,
|
|
62
|
+
timeout: Optional[float] = None,
|
|
63
|
+
max_retries: int = 3,
|
|
64
|
+
retry_delay: float = 1.0,
|
|
65
|
+
):
|
|
66
|
+
"""Initialize Client with configurable parameters.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
api_key: API key for authentication. If None, will try to load from environment
|
|
70
|
+
verify_ssl: Whether to verify SSL certificates
|
|
71
|
+
timeout: Request timeout in seconds. None means no timeout (infinite)
|
|
72
|
+
max_retries: Maximum number of retry attempts
|
|
73
|
+
retry_delay: Delay between retries in seconds
|
|
74
|
+
"""
|
|
75
|
+
logger.info("🔑 Initializing Client")
|
|
76
|
+
|
|
77
|
+
# Try to get API key from environment if not provided
|
|
78
|
+
if api_key is None:
|
|
79
|
+
from os import getenv
|
|
80
|
+
|
|
81
|
+
api_key = getenv("VISCRIBE_API_KEY")
|
|
82
|
+
if not api_key:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"VISCRIBE_API_KEY not provided and not found in environment"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
validate_api_key(api_key)
|
|
88
|
+
logger.debug(
|
|
89
|
+
f"🛠️ Configuration: verify_ssl={verify_ssl}, timeout={timeout}, max_retries={max_retries}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.api_key = api_key
|
|
93
|
+
self.headers = {**DEFAULT_HEADERS, "VISCRIBE-APIKEY": api_key}
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
self.retry_delay = retry_delay
|
|
97
|
+
|
|
98
|
+
# Create a session for connection pooling
|
|
99
|
+
self.session = requests.Session()
|
|
100
|
+
self.session.headers.update(self.headers)
|
|
101
|
+
self.session.verify = verify_ssl
|
|
102
|
+
|
|
103
|
+
# Configure retries
|
|
104
|
+
adapter = requests.adapters.HTTPAdapter(
|
|
105
|
+
max_retries=requests.urllib3.Retry(
|
|
106
|
+
total=max_retries,
|
|
107
|
+
backoff_factor=retry_delay,
|
|
108
|
+
status_forcelist=[500, 502, 503, 504],
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
self.session.mount("http://", adapter)
|
|
112
|
+
self.session.mount("https://", adapter)
|
|
113
|
+
|
|
114
|
+
# Add warning suppression if verify_ssl is False
|
|
115
|
+
if not verify_ssl:
|
|
116
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
117
|
+
|
|
118
|
+
logger.info("✅ Client initialized successfully")
|
|
119
|
+
|
|
120
|
+
def _make_request(self, method: str, url: str, **kwargs) -> Any:
|
|
121
|
+
"""Make HTTP request with error handling."""
|
|
122
|
+
try:
|
|
123
|
+
logger.info(f"🚀 Making {method} request to {url}")
|
|
124
|
+
logger.debug(f"🔍 Request parameters: {kwargs}")
|
|
125
|
+
|
|
126
|
+
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
|
127
|
+
logger.debug(f"📥 Response status: {response.status_code}")
|
|
128
|
+
|
|
129
|
+
result = handle_sync_response(response)
|
|
130
|
+
logger.info(f"✅ Request completed successfully: {method} {url}")
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
except RequestException as e:
|
|
134
|
+
logger.error(f"❌ Request failed: {str(e)}")
|
|
135
|
+
if hasattr(e, "response") and e.response is not None:
|
|
136
|
+
try:
|
|
137
|
+
error_data = e.response.json()
|
|
138
|
+
error_msg = error_data.get("error", str(e))
|
|
139
|
+
logger.error(f"🔴 API Error: {error_msg}")
|
|
140
|
+
raise APIError(error_msg, status_code=e.response.status_code)
|
|
141
|
+
except ValueError:
|
|
142
|
+
logger.error("🔴 Could not parse error response")
|
|
143
|
+
raise APIError(
|
|
144
|
+
str(e),
|
|
145
|
+
status_code=(
|
|
146
|
+
e.response.status_code
|
|
147
|
+
if hasattr(e.response, "status_code")
|
|
148
|
+
else None
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
logger.error(f"🔴 Connection Error: {str(e)}")
|
|
152
|
+
raise ConnectionError(f"Failed to connect to API: {str(e)}")
|
|
153
|
+
|
|
154
|
+
def submit_feedback(self, request_id: str, rating: int, feedback_text: Optional[str] = None) -> FeedbackResponse:
|
|
155
|
+
"""Submit feedback for a request"""
|
|
156
|
+
logger.info(f"📝 Submitting feedback for request {request_id}")
|
|
157
|
+
request = FeedbackRequest(
|
|
158
|
+
request_id=request_id,
|
|
159
|
+
rating=rating,
|
|
160
|
+
feedback_text=feedback_text,
|
|
161
|
+
)
|
|
162
|
+
payload = request.model_dump(exclude_none=True)
|
|
163
|
+
result = self._make_request(
|
|
164
|
+
"POST", f"{API_BASE_URL}/feedback", json=payload
|
|
165
|
+
)
|
|
166
|
+
return FeedbackResponse(**result)
|
|
167
|
+
|
|
168
|
+
def get_credits(self) -> CreditsResponse:
|
|
169
|
+
"""Get credits information"""
|
|
170
|
+
logger.info("💳 Fetching credits information")
|
|
171
|
+
result = self._make_request("GET", f"{API_BASE_URL}/credits")
|
|
172
|
+
return CreditsResponse(**result)
|
|
173
|
+
|
|
174
|
+
def describe_image(
|
|
175
|
+
self,
|
|
176
|
+
image_url: str = None,
|
|
177
|
+
image_base64: str = None,
|
|
178
|
+
instruction: str = None,
|
|
179
|
+
generate_tags: bool = True,
|
|
180
|
+
):
|
|
181
|
+
"""Send a describe image request with explicit arguments and validate with Pydantic."""
|
|
182
|
+
logger.info("🔍 Starting image describe request")
|
|
183
|
+
|
|
184
|
+
req = ImageDescribeRequest(
|
|
185
|
+
image_url=image_url,
|
|
186
|
+
image_base64=image_base64,
|
|
187
|
+
instruction=instruction,
|
|
188
|
+
generate_tags=generate_tags,
|
|
189
|
+
)
|
|
190
|
+
payload = req.model_dump(exclude_none=True)
|
|
191
|
+
result = self._make_request(
|
|
192
|
+
"POST", f"{API_BASE_URL}/images/describe", json=payload
|
|
193
|
+
)
|
|
194
|
+
return ImageDescribeResponse(**result)
|
|
195
|
+
|
|
196
|
+
def extract_image(
|
|
197
|
+
self,
|
|
198
|
+
image_url: str = None,
|
|
199
|
+
image_base64: str = None,
|
|
200
|
+
fields: list = None,
|
|
201
|
+
advanced_schema: Union[dict, BaseModel, Type[BaseModel]] = None,
|
|
202
|
+
instruction: str = None,
|
|
203
|
+
):
|
|
204
|
+
"""Send an extract structured data from image request with explicit arguments and validate with Pydantic.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
image_url: URL of the image
|
|
208
|
+
image_base64: Base64 encoded string of the image
|
|
209
|
+
fields: List of dictionaries with 'name', 'type', and optional 'description' keys.
|
|
210
|
+
Each field type can be 'text', 'number', 'array_text' (max 5), or 'array_number' (max 5).
|
|
211
|
+
Max 10 fields allowed.
|
|
212
|
+
advanced_schema: Full JSON schema for complex extraction (dict), a Pydantic BaseModel instance,
|
|
213
|
+
or a Pydantic BaseModel class. If a BaseModel is provided, it will be serialized
|
|
214
|
+
to its JSON schema. Use this for nested structures.
|
|
215
|
+
instruction: Optional instruction to guide the extraction process.
|
|
216
|
+
|
|
217
|
+
Note: Either fields or advanced_schema must be provided, not both.
|
|
218
|
+
"""
|
|
219
|
+
logger.info("🔍 Starting image extract request")
|
|
220
|
+
|
|
221
|
+
# Convert dictionaries to ExtractField models if fields are provided
|
|
222
|
+
validated_fields = None
|
|
223
|
+
if fields is not None:
|
|
224
|
+
validated_fields = [ExtractField(**field) if isinstance(field, dict) else field for field in fields]
|
|
225
|
+
|
|
226
|
+
# Convert Pydantic BaseModel to JSON schema if advanced_schema is a BaseModel
|
|
227
|
+
validated_schema = advanced_schema
|
|
228
|
+
if advanced_schema is not None:
|
|
229
|
+
if isinstance(advanced_schema, BaseModel):
|
|
230
|
+
# BaseModel instance
|
|
231
|
+
validated_schema = advanced_schema.model_json_schema()
|
|
232
|
+
elif isinstance(advanced_schema, type) and issubclass(advanced_schema, BaseModel):
|
|
233
|
+
# BaseModel class
|
|
234
|
+
validated_schema = advanced_schema.model_json_schema()
|
|
235
|
+
|
|
236
|
+
req = ImageExtractRequest(
|
|
237
|
+
image_url=image_url,
|
|
238
|
+
image_base64=image_base64,
|
|
239
|
+
fields=validated_fields,
|
|
240
|
+
advanced_schema=validated_schema,
|
|
241
|
+
instruction=instruction,
|
|
242
|
+
)
|
|
243
|
+
payload = req.model_dump(exclude_none=True)
|
|
244
|
+
result = self._make_request(
|
|
245
|
+
"POST", f"{API_BASE_URL}/images/extract", json=payload
|
|
246
|
+
)
|
|
247
|
+
return ImageExtractResponse(**result)
|
|
248
|
+
|
|
249
|
+
def classify_image(
|
|
250
|
+
self,
|
|
251
|
+
image_url: str = None,
|
|
252
|
+
image_base64: str = None,
|
|
253
|
+
classes: list = None,
|
|
254
|
+
class_descriptions: dict = None,
|
|
255
|
+
instruction: str = None,
|
|
256
|
+
multi_label: bool = False,
|
|
257
|
+
):
|
|
258
|
+
"""Send an image classify request with explicit arguments and validate with Pydantic."""
|
|
259
|
+
logger.info("🔍 Starting image classify request")
|
|
260
|
+
|
|
261
|
+
req = ImageClassifyRequest(
|
|
262
|
+
image_url=image_url,
|
|
263
|
+
image_base64=image_base64,
|
|
264
|
+
classes=classes,
|
|
265
|
+
class_descriptions=class_descriptions,
|
|
266
|
+
instruction=instruction,
|
|
267
|
+
multi_label=multi_label,
|
|
268
|
+
)
|
|
269
|
+
payload = req.model_dump(exclude_none=True)
|
|
270
|
+
result = self._make_request(
|
|
271
|
+
"POST", f"{API_BASE_URL}/images/classify", json=payload
|
|
272
|
+
)
|
|
273
|
+
return ImageClassifyResponse(**result)
|
|
274
|
+
|
|
275
|
+
def ask_image(
|
|
276
|
+
self,
|
|
277
|
+
image_url: str = None,
|
|
278
|
+
image_base64: str = None,
|
|
279
|
+
question: str = None,
|
|
280
|
+
):
|
|
281
|
+
"""Send an image VQA (ask) request with explicit arguments and validate with Pydantic."""
|
|
282
|
+
logger.info("🔍 Starting image ask (VQA) request")
|
|
283
|
+
|
|
284
|
+
req = ImageAskRequest(
|
|
285
|
+
image_url=image_url,
|
|
286
|
+
image_base64=image_base64,
|
|
287
|
+
question=question,
|
|
288
|
+
)
|
|
289
|
+
payload = req.model_dump(exclude_none=True)
|
|
290
|
+
result = self._make_request("POST", f"{API_BASE_URL}/images/ask", json=payload)
|
|
291
|
+
return ImageAskResponse(**result)
|
|
292
|
+
|
|
293
|
+
def compare_images(
|
|
294
|
+
self,
|
|
295
|
+
image1_url: str = None,
|
|
296
|
+
image1_base64: str = None,
|
|
297
|
+
image2_url: str = None,
|
|
298
|
+
image2_base64: str = None,
|
|
299
|
+
instruction: str = None,
|
|
300
|
+
):
|
|
301
|
+
"""Send an image compare request with explicit arguments and validate with Pydantic."""
|
|
302
|
+
logger.info("🔍 Starting image compare request")
|
|
303
|
+
|
|
304
|
+
req = ImageCompareRequest(
|
|
305
|
+
image1_url=image1_url,
|
|
306
|
+
image1_base64=image1_base64,
|
|
307
|
+
image2_url=image2_url,
|
|
308
|
+
image2_base64=image2_base64,
|
|
309
|
+
instruction=instruction,
|
|
310
|
+
)
|
|
311
|
+
payload = req.model_dump(exclude_none=True)
|
|
312
|
+
result = self._make_request(
|
|
313
|
+
"POST", f"{API_BASE_URL}/images/compare", json=payload
|
|
314
|
+
)
|
|
315
|
+
return ImageCompareResponse(**result)
|
|
316
|
+
|
|
317
|
+
def close(self):
|
|
318
|
+
"""Close the session to free up resources"""
|
|
319
|
+
logger.info("🔒 Closing Client session")
|
|
320
|
+
self.session.close()
|
|
321
|
+
logger.debug("✅ Session closed successfully")
|
|
322
|
+
|
|
323
|
+
def __enter__(self):
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
327
|
+
self.close()
|
viscribe/config.py
ADDED
viscribe/exceptions.py
ADDED
viscribe/logger.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import logging.handlers
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
# Emoji mappings for different log levels
|
|
6
|
+
LOG_EMOJIS: Dict[int, str] = {
|
|
7
|
+
logging.DEBUG: "🐛",
|
|
8
|
+
logging.INFO: "💬",
|
|
9
|
+
logging.WARNING: "⚠️",
|
|
10
|
+
logging.ERROR: "❌",
|
|
11
|
+
logging.CRITICAL: "🚨",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EmojiFormatter(logging.Formatter):
|
|
16
|
+
"""Custom formatter that adds emojis to log messages"""
|
|
17
|
+
|
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
19
|
+
# Add emoji based on log level
|
|
20
|
+
emoji = LOG_EMOJIS.get(record.levelno, "")
|
|
21
|
+
record.emoji = emoji
|
|
22
|
+
return super().format(record)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ViscribeLogger:
|
|
26
|
+
"""Class to manage Viscribe logging configuration"""
|
|
27
|
+
|
|
28
|
+
_instance = None
|
|
29
|
+
_initialized = False
|
|
30
|
+
|
|
31
|
+
def __new__(cls):
|
|
32
|
+
if cls._instance is None:
|
|
33
|
+
cls._instance = super(ViscribeLogger, cls).__new__(cls)
|
|
34
|
+
return cls._instance
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
if not self._initialized:
|
|
38
|
+
self.logger = logging.getLogger("viscribe")
|
|
39
|
+
self.logger.setLevel(logging.INFO)
|
|
40
|
+
self.enabled = False
|
|
41
|
+
self._initialized = True
|
|
42
|
+
|
|
43
|
+
def set_logging(
|
|
44
|
+
self,
|
|
45
|
+
level: Optional[str] = None,
|
|
46
|
+
log_file: Optional[str] = None,
|
|
47
|
+
log_format: Optional[str] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Configure logging settings. If level is None, logging will be disabled.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
level: Logging level (e.g., 'DEBUG', 'INFO'). None to disable logging.
|
|
54
|
+
log_file: Optional file path to write logs to
|
|
55
|
+
log_format: Optional custom log format string
|
|
56
|
+
"""
|
|
57
|
+
# Clear existing handlers
|
|
58
|
+
self.logger.handlers.clear()
|
|
59
|
+
|
|
60
|
+
if level is None:
|
|
61
|
+
# Disable logging
|
|
62
|
+
self.enabled = False
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Enable logging with specified level
|
|
66
|
+
self.enabled = True
|
|
67
|
+
level = getattr(logging, level.upper(), logging.INFO)
|
|
68
|
+
self.logger.setLevel(level)
|
|
69
|
+
|
|
70
|
+
# Default format if none provided
|
|
71
|
+
if not log_format:
|
|
72
|
+
log_format = "%(emoji)s %(asctime)-15s %(message)s"
|
|
73
|
+
|
|
74
|
+
formatter = EmojiFormatter(log_format)
|
|
75
|
+
|
|
76
|
+
# Console handler
|
|
77
|
+
console_handler = logging.StreamHandler()
|
|
78
|
+
console_handler.setFormatter(formatter)
|
|
79
|
+
self.logger.addHandler(console_handler)
|
|
80
|
+
|
|
81
|
+
# File handler if log_file specified
|
|
82
|
+
if log_file:
|
|
83
|
+
file_handler = logging.FileHandler(log_file)
|
|
84
|
+
file_handler.setFormatter(formatter)
|
|
85
|
+
self.logger.addHandler(file_handler)
|
|
86
|
+
|
|
87
|
+
def disable(self) -> None:
|
|
88
|
+
"""Disable all logging"""
|
|
89
|
+
self.logger.handlers.clear()
|
|
90
|
+
self.enabled = False
|
|
91
|
+
|
|
92
|
+
def debug(self, message: str) -> None:
|
|
93
|
+
"""Log debug message if logging is enabled"""
|
|
94
|
+
if self.enabled:
|
|
95
|
+
self.logger.debug(message)
|
|
96
|
+
|
|
97
|
+
def info(self, message: str) -> None:
|
|
98
|
+
"""Log info message if logging is enabled"""
|
|
99
|
+
if self.enabled:
|
|
100
|
+
self.logger.info(message)
|
|
101
|
+
|
|
102
|
+
def warning(self, message: str) -> None:
|
|
103
|
+
"""Log warning message if logging is enabled"""
|
|
104
|
+
if self.enabled:
|
|
105
|
+
self.logger.warning(message)
|
|
106
|
+
|
|
107
|
+
def error(self, message: str) -> None:
|
|
108
|
+
"""Log error message if logging is enabled"""
|
|
109
|
+
if self.enabled:
|
|
110
|
+
self.logger.error(message)
|
|
111
|
+
|
|
112
|
+
def critical(self, message: str) -> None:
|
|
113
|
+
"""Log critical message if logging is enabled"""
|
|
114
|
+
if self.enabled:
|
|
115
|
+
self.logger.critical(message)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Default logger instance
|
|
119
|
+
viscribe_logger = ViscribeLogger()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .image import (
|
|
2
|
+
CreditsResponse,
|
|
3
|
+
ExtractField,
|
|
4
|
+
FeedbackRequest,
|
|
5
|
+
FeedbackResponse,
|
|
6
|
+
ImageAskRequest,
|
|
7
|
+
ImageAskResponse,
|
|
8
|
+
ImageClassifyRequest,
|
|
9
|
+
ImageClassifyResponse,
|
|
10
|
+
ImageCompareRequest,
|
|
11
|
+
ImageCompareResponse,
|
|
12
|
+
ImageDescribeRequest,
|
|
13
|
+
ImageDescribeResponse,
|
|
14
|
+
ImageExtractRequest,
|
|
15
|
+
ImageExtractResponse,
|
|
16
|
+
)
|
viscribe/models/image.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from viscribe.utils.helpers import validate_base64_image, validate_url_format
|
|
8
|
+
|
|
9
|
+
# 1. Image Endpoints
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImageSourceBase(BaseModel):
|
|
13
|
+
image_url: Optional[str] = Field(default=None, description="URL of the image.")
|
|
14
|
+
image_base64: Optional[str] = Field(
|
|
15
|
+
default=None, description="Base64 encoded string of the image."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
@model_validator(mode="before")
|
|
19
|
+
@classmethod
|
|
20
|
+
def check_image_source(cls, values):
|
|
21
|
+
"""Ensure exactly one image source is provided and validate formats."""
|
|
22
|
+
url = values.get("image_url")
|
|
23
|
+
b64 = values.get("image_base64")
|
|
24
|
+
|
|
25
|
+
if not url and not b64:
|
|
26
|
+
raise ValueError("Either image_url or image_base64 must be provided.")
|
|
27
|
+
if url and b64:
|
|
28
|
+
raise ValueError("Provide either image_url or image_base64, not both.")
|
|
29
|
+
|
|
30
|
+
# Validate URL format if provided
|
|
31
|
+
if url:
|
|
32
|
+
try:
|
|
33
|
+
validate_url_format(url)
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
raise ValueError(f"Invalid image_url: {str(e)}")
|
|
36
|
+
|
|
37
|
+
# Validate base64 format if provided
|
|
38
|
+
if b64:
|
|
39
|
+
try:
|
|
40
|
+
validate_base64_image(b64)
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
raise ValueError(f"Invalid image_base64: {str(e)}")
|
|
43
|
+
|
|
44
|
+
return values
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ImageDescribeRequest(ImageSourceBase):
|
|
48
|
+
instruction: Optional[str] = None
|
|
49
|
+
generate_tags: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ImageDescribeResponse(BaseModel):
|
|
53
|
+
request_id: str
|
|
54
|
+
credits_used: int
|
|
55
|
+
image_description: str
|
|
56
|
+
tags: Optional[List[str]] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ExtractField(BaseModel):
|
|
60
|
+
"""Simple field definition for extraction."""
|
|
61
|
+
|
|
62
|
+
name: str = Field(..., description="Field name")
|
|
63
|
+
type: str = Field(
|
|
64
|
+
..., description="Field type: 'text', 'number', 'array_text', or 'array_number'"
|
|
65
|
+
)
|
|
66
|
+
description: Optional[str] = Field(
|
|
67
|
+
None, description="Optional description to guide extraction"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@field_validator("type")
|
|
71
|
+
@classmethod
|
|
72
|
+
def validate_type(cls, v):
|
|
73
|
+
valid_types = ["text", "number", "array_text", "array_number"]
|
|
74
|
+
if v not in valid_types:
|
|
75
|
+
raise ValueError(f"type must be one of {valid_types}")
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ImageExtractRequest(ImageSourceBase):
|
|
80
|
+
fields: Optional[List[ExtractField]] = Field(
|
|
81
|
+
default=None,
|
|
82
|
+
description="Simple list of fields to extract (max 10). Each field can be text, number, array_text (max 5), or array_number (max 5).",
|
|
83
|
+
max_length=10,
|
|
84
|
+
)
|
|
85
|
+
advanced_schema: Optional[dict] = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="Advanced: Full JSON schema for complex extraction. Use this for nested structures.",
|
|
88
|
+
)
|
|
89
|
+
instruction: Optional[str] = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
description="Optional instruction to guide the extraction process.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def check_schema_or_fields(self):
|
|
96
|
+
"""Ensure either fields or advanced_schema is provided, not both."""
|
|
97
|
+
if not self.fields and not self.advanced_schema:
|
|
98
|
+
raise ValueError("Either 'fields' or 'advanced_schema' must be provided")
|
|
99
|
+
if self.fields and self.advanced_schema:
|
|
100
|
+
raise ValueError("Provide either 'fields' or 'advanced_schema', not both")
|
|
101
|
+
|
|
102
|
+
# Validate advanced_schema if provided
|
|
103
|
+
if self.advanced_schema:
|
|
104
|
+
if (
|
|
105
|
+
not isinstance(self.advanced_schema, dict)
|
|
106
|
+
or self.advanced_schema.get("type") != "object"
|
|
107
|
+
or not self.advanced_schema.get("properties")
|
|
108
|
+
):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"advanced_schema must be a valid JSON object schema with 'type': 'object' and a 'properties' field."
|
|
111
|
+
)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ImageExtractResponse(BaseModel):
|
|
116
|
+
request_id: str
|
|
117
|
+
credits_used: int
|
|
118
|
+
extracted_data: Dict[str, Any]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ImageClassifyRequest(ImageSourceBase):
|
|
122
|
+
classes: Optional[List[str]] = None
|
|
123
|
+
class_descriptions: Optional[Dict[str, str]] = None
|
|
124
|
+
instruction: Optional[str] = None
|
|
125
|
+
multi_label: bool = False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ImageClassifyResponse(BaseModel):
|
|
129
|
+
request_id: str
|
|
130
|
+
credits_used: int
|
|
131
|
+
classification: List[str]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ImageAskRequest(ImageSourceBase):
|
|
135
|
+
question: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ImageAskResponse(BaseModel):
|
|
139
|
+
request_id: str
|
|
140
|
+
credits_used: int
|
|
141
|
+
answer: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ImageCompareRequest(BaseModel):
|
|
145
|
+
image1_url: Optional[str] = None
|
|
146
|
+
image1_base64: Optional[str] = None
|
|
147
|
+
image2_url: Optional[str] = None
|
|
148
|
+
image2_base64: Optional[str] = None
|
|
149
|
+
instruction: Optional[str] = Field(
|
|
150
|
+
default="Describe the similarities and differences between these two images."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ImageCompareResponse(BaseModel):
|
|
155
|
+
request_id: str
|
|
156
|
+
credits_used: int
|
|
157
|
+
comparison_result: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# 2. User Endpoints
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CreditsResponse(BaseModel):
|
|
164
|
+
remaining_credits: int
|
|
165
|
+
total_credits_used: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class FeedbackRequest(BaseModel):
|
|
169
|
+
"""Request model for feedback endpoint"""
|
|
170
|
+
|
|
171
|
+
request_id: str = Field(..., example="123e4567-e89b-12d3-a456-426614174000")
|
|
172
|
+
rating: int = Field(..., ge=1, le=5, example=5)
|
|
173
|
+
feedback_text: Optional[str] = Field(None, example="Great results!")
|
|
174
|
+
|
|
175
|
+
@model_validator(mode="after")
|
|
176
|
+
def validate_request_id(self) -> "FeedbackRequest":
|
|
177
|
+
try:
|
|
178
|
+
UUID(self.request_id)
|
|
179
|
+
except ValueError:
|
|
180
|
+
raise ValueError("request_id must be a valid UUID")
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class FeedbackResponse(BaseModel):
|
|
185
|
+
feedback_id: UUID
|
|
186
|
+
request_id: UUID
|
|
187
|
+
message: str
|
|
188
|
+
feedback_timestamp: datetime
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Utility functions go here
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from requests import Response
|
|
10
|
+
|
|
11
|
+
from viscribe.exceptions import APIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_api_key(api_key: str) -> bool:
|
|
15
|
+
if not api_key.startswith("vscrb-"):
|
|
16
|
+
raise ValueError("Invalid API key format. API key must start with 'vscrb-'")
|
|
17
|
+
uuid_part = api_key[5:] # Strip out 'vscrb-'
|
|
18
|
+
try:
|
|
19
|
+
UUID(uuid_part)
|
|
20
|
+
except ValueError:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"Invalid API key format. API key must be 'vscrb-' followed by a valid UUID. You can get one at https://app.viscribe.ai/"
|
|
23
|
+
)
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def handle_sync_response(response: Response) -> Dict[str, Any]:
|
|
28
|
+
data = response.json()
|
|
29
|
+
|
|
30
|
+
if response.status_code >= 400:
|
|
31
|
+
error_msg = data.get("error", "Unknown error occurred")
|
|
32
|
+
raise APIError(error_msg, status_code=response.status_code)
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def handle_async_response(response: aiohttp.ClientResponse) -> Dict[str, Any]:
|
|
38
|
+
data = await response.json()
|
|
39
|
+
|
|
40
|
+
if response.status >= 400:
|
|
41
|
+
error_msg = data.get("error", "Unknown error occurred")
|
|
42
|
+
raise APIError(error_msg, status_code=response.status)
|
|
43
|
+
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_url_format(url: str) -> bool:
|
|
48
|
+
"""Validate URL format."""
|
|
49
|
+
try:
|
|
50
|
+
result = urlparse(url)
|
|
51
|
+
if not all([result.scheme, result.netloc]):
|
|
52
|
+
raise ValueError("URL must have a valid scheme and netloc")
|
|
53
|
+
if result.scheme not in ["http", "https"]:
|
|
54
|
+
raise ValueError("URL scheme must be http or https")
|
|
55
|
+
return True
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise ValueError(f"Invalid URL format: {str(e)}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_base64_image(b64: str) -> bool:
|
|
63
|
+
"""Validate base64 encoded image format."""
|
|
64
|
+
try:
|
|
65
|
+
# Check if it's a data URL
|
|
66
|
+
if b64.startswith("data:image/"):
|
|
67
|
+
# Extract the base64 part after the comma
|
|
68
|
+
b64_part = b64.split(",", 1)[1] if "," in b64 else b64
|
|
69
|
+
else:
|
|
70
|
+
b64_part = b64
|
|
71
|
+
|
|
72
|
+
# Try to decode
|
|
73
|
+
decoded = base64.b64decode(b64_part, validate=True)
|
|
74
|
+
if len(decoded) == 0:
|
|
75
|
+
raise ValueError("Base64 string is empty after decoding")
|
|
76
|
+
return True
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise ValueError(f"Invalid base64 image format: {str(e)}")
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: viscribe
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: ViscribeAI SDK
|
|
5
|
+
Author-email: viscriber0 <contact@viscribe.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,api,artificial intelligence,gpt,image,image2text,llm,machine learning,multimodal,natural language processing,nlp,openai,sdk
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Requires-Python: <4.0,>=3.10
|
|
13
|
+
Requires-Dist: aiohttp>=3.10
|
|
14
|
+
Requires-Dist: beautifulsoup4>=4.13.4
|
|
15
|
+
Requires-Dist: pydantic>=2.10.2
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
17
|
+
Requires-Dist: requests>=2.32.3
|
|
18
|
+
Provides-Extra: docs
|
|
19
|
+
Requires-Dist: furo==2024.5.6; extra == 'docs'
|
|
20
|
+
Requires-Dist: sphinx==6.0; extra == 'docs'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
<a href="https://viscribe.ai"><img src="assets/viscribe-logo.png" alt="Viscribe Logo" width="200"></a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
# 🌐 ViscribeAI - Python SDK
|
|
28
|
+
|
|
29
|
+
[](https://badge.fury.io/py/viscribe)
|
|
30
|
+
[](https://pypi.org/project/viscribe/)
|
|
31
|
+
[](https://opensource.org/licenses/MIT)
|
|
32
|
+
[](https://github.com/psf/black)
|
|
33
|
+
[](https://docs.viscribe.ai)
|
|
34
|
+
|
|
35
|
+
Official [Python SDK](https://viscribe.ai) for ViscribeAI - AI-powered image understanding and analysis.
|
|
36
|
+
|
|
37
|
+
> 🎁 **Get started with free credits!** Visit [dashboard.viscribe.ai](https://dashboard.viscribe.ai) to sign up and get your API key.
|
|
38
|
+
|
|
39
|
+
## 📦 Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install viscribe
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 🚀 Features
|
|
46
|
+
|
|
47
|
+
- 🖼️ AI-powered image description, extraction, classification, VQA (Visual Question Answering), and comparison
|
|
48
|
+
- 🔄 Both sync and async clients
|
|
49
|
+
- 📊 Structured output with Pydantic schemas
|
|
50
|
+
- 🔍 Detailed logging
|
|
51
|
+
- ⚡ Automatic retries
|
|
52
|
+
- 🔐 Secure authentication
|
|
53
|
+
|
|
54
|
+
## 🎯 Quick Start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from viscribe import Client
|
|
58
|
+
|
|
59
|
+
client = Client(api_key="your-api-key-here")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> **Note:**
|
|
63
|
+
> You can set the `VISCRIBE_API_KEY` environment variable and initialize the client without parameters: `client = Client()`
|
|
64
|
+
|
|
65
|
+
## 📚 Image Endpoints
|
|
66
|
+
|
|
67
|
+
### 1. Describe Image
|
|
68
|
+
Generate a natural language description of an image, optionally with tags.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from viscribe.client import Client
|
|
72
|
+
client = Client(api_key="your-api-key-here")
|
|
73
|
+
|
|
74
|
+
resp = client.describe_image(
|
|
75
|
+
image_url="https://img.com/cat.jpg",
|
|
76
|
+
generate_tags=True
|
|
77
|
+
)
|
|
78
|
+
print(resp)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Classify Image
|
|
82
|
+
Classify an image into one or more categories.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
resp = client.classify_image(
|
|
86
|
+
image_url="https://img.com/cat.jpg",
|
|
87
|
+
classes=["cat", "dog"]
|
|
88
|
+
)
|
|
89
|
+
print(resp)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Visual Question Answering (VQA)
|
|
93
|
+
Ask a question about the content of an image and get an answer.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
resp = client.ask_image(
|
|
97
|
+
image_url="https://img.com/car.jpg",
|
|
98
|
+
question="What color is the car?"
|
|
99
|
+
)
|
|
100
|
+
print(resp)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 4. Extract Structured Data from Image
|
|
104
|
+
Extract structured data from an image using either simple fields or an advanced schema.
|
|
105
|
+
|
|
106
|
+
#### Simple Fields (Recommended for basic extraction)
|
|
107
|
+
Use simple fields for straightforward data extraction (max 10 fields):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
resp = client.extract_image(
|
|
111
|
+
image_url="https://img.com/prod.jpg",
|
|
112
|
+
fields=[
|
|
113
|
+
{"name": "product_name", "type": "text", "description": "Name of the product"},
|
|
114
|
+
{"name": "price", "type": "number", "description": "Product price"},
|
|
115
|
+
{"name": "tags", "type": "array_text", "description": "Product tags"},
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
print(resp.extracted_data)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Field Types:**
|
|
122
|
+
- `text`: Single text value
|
|
123
|
+
- `number`: Single numeric value
|
|
124
|
+
- `array_text`: Array of text values (max 5 items)
|
|
125
|
+
- `array_number`: Array of numeric values (max 5 items)
|
|
126
|
+
|
|
127
|
+
#### Advanced Schema (For complex/nested structures)
|
|
128
|
+
Use advanced schema for complex nested structures or when you need more control:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from pydantic import BaseModel
|
|
132
|
+
|
|
133
|
+
class Product(BaseModel):
|
|
134
|
+
product_name: str
|
|
135
|
+
price: float
|
|
136
|
+
specifications: dict
|
|
137
|
+
|
|
138
|
+
resp = client.extract_image(
|
|
139
|
+
image_url="https://img.com/prod.jpg",
|
|
140
|
+
advanced_schema=Product # Pass the class directly
|
|
141
|
+
)
|
|
142
|
+
print(resp.extracted_data)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> **Note:** Either `fields` or `advanced_schema` must be provided, not both.
|
|
146
|
+
|
|
147
|
+
### 5. Compare Images
|
|
148
|
+
Compare two images and get a description of their similarities and differences.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
resp = client.compare_images(
|
|
152
|
+
image1_url="https://img.com/cat1.jpg",
|
|
153
|
+
image2_url="https://img.com/cat2.jpg"
|
|
154
|
+
)
|
|
155
|
+
print(resp)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 👤 User Endpoints
|
|
159
|
+
|
|
160
|
+
Check credits and submit feedback.
|
|
161
|
+
|
|
162
|
+
### Get Credits
|
|
163
|
+
```python
|
|
164
|
+
credits = client.get_credits()
|
|
165
|
+
print(credits)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Submit Feedback
|
|
169
|
+
```python
|
|
170
|
+
feedback_response = client.submit_feedback(
|
|
171
|
+
request_id="your-request-id",
|
|
172
|
+
rating=5, # Rating from 1-5
|
|
173
|
+
feedback_text="Perfect image description!",
|
|
174
|
+
)
|
|
175
|
+
print(feedback_response)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## ⚡ Async Usage
|
|
179
|
+
|
|
180
|
+
All endpoints support async operations:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import asyncio
|
|
184
|
+
from viscribe.async_client import AsyncClient
|
|
185
|
+
|
|
186
|
+
async def main():
|
|
187
|
+
client = AsyncClient(api_key="your-api-key-here")
|
|
188
|
+
resp = await client.describe_image({"image_url": "https://img.com/cat.jpg"})
|
|
189
|
+
print(resp)
|
|
190
|
+
# ... use other endpoints as above
|
|
191
|
+
|
|
192
|
+
asyncio.run(main())
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 📖 Documentation
|
|
196
|
+
|
|
197
|
+
For detailed documentation, visit [docs.viscribe.ai](https://docs.viscribe.ai)
|
|
198
|
+
|
|
199
|
+
## 🛠️ Development
|
|
200
|
+
|
|
201
|
+
For information about setting up the development environment and contributing to the project, see our [Contributing Guide](CONTRIBUTING.md).
|
|
202
|
+
|
|
203
|
+
## 💬 Support & Feedback
|
|
204
|
+
|
|
205
|
+
- 📧 Email: support@viscribe.ai
|
|
206
|
+
- 💻 GitHub Issues: [Create an issue](https://github.com/ViscribeAI/python-sdk/issues)
|
|
207
|
+
- 🌟 Feature Requests: [Request a feature](https://github.com/ViscribeAI/python-sdk/issues/new)
|
|
208
|
+
- ⭐ API Feedback: You can also submit feedback programmatically using the feedback endpoint:
|
|
209
|
+
```python
|
|
210
|
+
from viscribe import Client
|
|
211
|
+
|
|
212
|
+
client = Client(api_key="your-api-key-here")
|
|
213
|
+
|
|
214
|
+
client.submit_feedback(
|
|
215
|
+
request_id="your-request-id",
|
|
216
|
+
rating=5,
|
|
217
|
+
feedback_text="Great results!"
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## 📄 License
|
|
222
|
+
|
|
223
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
224
|
+
|
|
225
|
+
## 🔗 Links
|
|
226
|
+
|
|
227
|
+
- [Website](https://viscribe.ai)
|
|
228
|
+
- [Documentation](https://docs.viscribe.ai)
|
|
229
|
+
- [GitHub](https://github.com/ViscribeAI/python-sdk)
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
> 🎁 **Get started with free credits!** Visit [dashboard.viscribe.ai](https://dashboard.viscribe.ai) to sign up and get your API key.
|
|
234
|
+
|
|
235
|
+
Made with ❤️ by [ViscribeAI](https://viscribe.ai)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
viscribe/__init__.py,sha256=9v9X-o8CGf5goWydq-BeHY0U8BVEiDrTk_o15YQXIp4,106
|
|
2
|
+
viscribe/async_client.py,sha256=_kuI_B1yI4YImEl0j3-7FjOgkuo6uSPsXwrKwbyGwa0,12387
|
|
3
|
+
viscribe/client.py,sha256=aI2w4TUQBBVKWJGVp0RMdkhTxUVY9icrieeiJ1qw2t8,12277
|
|
4
|
+
viscribe/config.py,sha256=0Qp5Tf-J1q2c-rNFdEViZjQfW8U5xJCzzcUUWKEghYg,170
|
|
5
|
+
viscribe/exceptions.py,sha256=TFROaihQQAmLO56s3J-st0BR74xeqjHT_tpzgrMaM74,264
|
|
6
|
+
viscribe/logger.py,sha256=Sdli1r1KG2NzMaPRHVCiNHiI6_ykifHKPh7hor-MrsY,3555
|
|
7
|
+
viscribe/models/__init__.py,sha256=uvr4voFaDXYc4JSomF8SXrFJNmFhNPgX-gfDEuVAGDA,356
|
|
8
|
+
viscribe/models/image.py,sha256=rsDGq32Q9-iO67G1wJ692xdk8R8Vxyoaksyad3662to,5706
|
|
9
|
+
viscribe/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
viscribe/utils/helpers.py,sha256=fzdRK_QhQBsOEWEJxaedVraSEjdzuE3GFLqqtH9u2eY,2431
|
|
11
|
+
viscribe-1.0.1.dist-info/METADATA,sha256=KlFdb_GwQxrdTuvepk-aKryhfHOhvIXvkVJO3kPxcYQ,6718
|
|
12
|
+
viscribe-1.0.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
13
|
+
viscribe-1.0.1.dist-info/licenses/LICENSE,sha256=PO1JKSHKXkev7dpsYZIgd-wXxIzigpPunGgQQa3dNA4,1067
|
|
14
|
+
viscribe-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ViscribeAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|