embed-client 1.0.0.1__tar.gz → 2.0.0.0__tar.gz

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.
Files changed (25) hide show
  1. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/PKG-INFO +1 -1
  2. embed_client-2.0.0.0/README.md +24 -0
  3. embed_client-2.0.0.0/embed_client/async_client.py +607 -0
  4. embed_client-2.0.0.0/embed_client/example_async_usage.py +165 -0
  5. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client/example_async_usage_ru.py +42 -8
  6. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client.egg-info/PKG-INFO +1 -1
  7. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client.egg-info/SOURCES.txt +2 -0
  8. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/pyproject.toml +1 -1
  9. embed_client-2.0.0.0/tests/test_async_client.py +1116 -0
  10. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/tests/test_async_client_real.py +51 -21
  11. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/tests/test_async_client_stress.py +27 -1
  12. embed_client-2.0.0.0/tests/test_async_client_stress_new.py +319 -0
  13. embed_client-2.0.0.0/tests/test_async_client_stress_updated.py +319 -0
  14. embed_client-2.0.0.0/tests/test_example_async_usage.py +79 -0
  15. embed_client-1.0.0.1/README.md +0 -1
  16. embed_client-1.0.0.1/embed_client/async_client.py +0 -266
  17. embed_client-1.0.0.1/embed_client/example_async_usage.py +0 -69
  18. embed_client-1.0.0.1/tests/test_async_client.py +0 -317
  19. embed_client-1.0.0.1/tests/test_example_async_usage.py +0 -29
  20. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client/__init__.py +0 -0
  21. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client.egg-info/dependency_links.txt +0 -0
  22. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client.egg-info/requires.txt +0 -0
  23. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/embed_client.egg-info/top_level.txt +0 -0
  24. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/setup.cfg +0 -0
  25. {embed_client-1.0.0.1 → embed_client-2.0.0.0}/tests/test_example_async_usage_ru.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: embed-client
3
- Version: 1.0.0.1
3
+ Version: 2.0.0.0
4
4
  Summary: Async client for Embedding Service API
5
5
  Author: Your Name
6
6
  Requires-Dist: aiohttp
@@ -0,0 +1,24 @@
1
+ # vvz-embed-client
2
+
3
+ ## Quick Start: Примеры запуска
4
+
5
+ **Вариант 1: через аргументы командной строки**
6
+
7
+ ```sh
8
+ python embed_client/example_async_usage.py --base-url http://localhost --port 8001
9
+ python embed_client/example_async_usage_ru.py --base-url http://localhost --port 8001
10
+ ```
11
+
12
+ **Вариант 2: через переменные окружения**
13
+
14
+ ```sh
15
+ export EMBED_CLIENT_BASE_URL=http://localhost
16
+ export EMBED_CLIENT_PORT=8001
17
+ python embed_client/example_async_usage.py
18
+ python embed_client/example_async_usage_ru.py
19
+ ```
20
+
21
+ **Важно:**
22
+ - Используйте `--base-url` (через дефис), а не `--base_url` (через подчеркивание).
23
+ - Значение base_url должно содержать `http://` или `https://`.
24
+ - Аргументы должны быть отдельными (через пробел), а не через `=`.
@@ -0,0 +1,607 @@
1
+ """
2
+ Async client for Embedding Service API (OpenAPI 3.0.2)
3
+
4
+ - 100% type-annotated
5
+ - English docstrings and examples
6
+ - Ready for PyPi
7
+ - Supports new API format with body, embedding, and chunks
8
+ """
9
+
10
+ from typing import Any, Dict, List, Optional, Union
11
+ import aiohttp
12
+ import asyncio
13
+ import os
14
+ import json
15
+ import logging
16
+
17
+ class EmbeddingServiceError(Exception):
18
+ """Base exception for EmbeddingServiceAsyncClient."""
19
+
20
+ class EmbeddingServiceConnectionError(EmbeddingServiceError):
21
+ """Raised when the service is unavailable or connection fails."""
22
+
23
+ class EmbeddingServiceHTTPError(EmbeddingServiceError):
24
+ """Raised for HTTP errors (4xx, 5xx)."""
25
+ def __init__(self, status: int, message: str):
26
+ super().__init__(f"HTTP {status}: {message}")
27
+ self.status = status
28
+ self.message = message
29
+
30
+ class EmbeddingServiceAPIError(EmbeddingServiceError):
31
+ """Raised for errors returned by the API in the response body."""
32
+ def __init__(self, error: Any):
33
+ super().__init__(f"API error: {error}")
34
+ self.error = error
35
+
36
+ class EmbeddingServiceConfigError(EmbeddingServiceError):
37
+ """Raised for configuration errors (invalid base_url, port, etc.)."""
38
+
39
+ class EmbeddingServiceTimeoutError(EmbeddingServiceError):
40
+ """Raised when request times out."""
41
+
42
+ class EmbeddingServiceJSONError(EmbeddingServiceError):
43
+ """Raised when JSON parsing fails."""
44
+
45
+ class EmbeddingServiceAsyncClient:
46
+ """
47
+ Asynchronous client for the Embedding Service API.
48
+
49
+ Supports both old and new API formats:
50
+ - Old format: {"result": {"success": true, "data": {"embeddings": [...]}}}
51
+ - New format: {"result": {"success": true, "data": {"embeddings": [...], "results": [{"body": "text", "embedding": [...], "tokens": [...], "bm25_tokens": [...]}]}}}
52
+
53
+ Args:
54
+ base_url (str): Base URL of the embedding service (e.g., "http://localhost").
55
+ port (int): Port of the embedding service (e.g., 8001).
56
+ timeout (float): Request timeout in seconds (default: 30).
57
+ Raises:
58
+ EmbeddingServiceConfigError: If base_url or port is invalid.
59
+ """
60
+ def __init__(self, base_url: Optional[str] = None, port: Optional[int] = None, timeout: float = 30.0):
61
+ # Validate and set base_url
62
+ try:
63
+ self.base_url = base_url or os.getenv("EMBEDDING_SERVICE_BASE_URL", "http://localhost")
64
+ if not self.base_url:
65
+ raise EmbeddingServiceConfigError("base_url must be provided.")
66
+ if not isinstance(self.base_url, str):
67
+ raise EmbeddingServiceConfigError("base_url must be a string.")
68
+
69
+ # Validate URL format
70
+ if not (self.base_url.startswith("http://") or self.base_url.startswith("https://")):
71
+ raise EmbeddingServiceConfigError("base_url must start with http:// or https://")
72
+ except (TypeError, AttributeError) as e:
73
+ raise EmbeddingServiceConfigError(f"Invalid base_url configuration: {e}") from e
74
+
75
+ # Validate and set port
76
+ try:
77
+ port_env = os.getenv("EMBEDDING_SERVICE_PORT", "8001")
78
+ self.port = port if port is not None else int(port_env)
79
+ if self.port is None:
80
+ raise EmbeddingServiceConfigError("port must be provided.")
81
+ if not isinstance(self.port, int) or self.port <= 0 or self.port > 65535:
82
+ raise EmbeddingServiceConfigError("port must be a valid integer between 1 and 65535.")
83
+ except (ValueError, TypeError) as e:
84
+ raise EmbeddingServiceConfigError(f"Invalid port configuration: {e}") from e
85
+
86
+ # Validate timeout
87
+ try:
88
+ self.timeout = float(timeout)
89
+ if self.timeout <= 0:
90
+ raise EmbeddingServiceConfigError("timeout must be positive.")
91
+ except (ValueError, TypeError) as e:
92
+ raise EmbeddingServiceConfigError(f"Invalid timeout configuration: {e}") from e
93
+
94
+ self._session: Optional[aiohttp.ClientSession] = None
95
+
96
+ def _make_url(self, path: str, base_url: Optional[str] = None, port: Optional[int] = None) -> str:
97
+ try:
98
+ url = (base_url or self.base_url).rstrip("/")
99
+ port_val = port if port is not None else self.port
100
+ return f"{url}:{port_val}{path}"
101
+ except Exception as e:
102
+ raise EmbeddingServiceConfigError(f"Failed to construct URL: {e}") from e
103
+
104
+ def _format_error_response(self, error: str, lang: Optional[str] = None, text: Optional[str] = None) -> Dict[str, Any]:
105
+ """
106
+ Format error response in a standard way.
107
+ Args:
108
+ error (str): Error message
109
+ lang (str, optional): Language of the text that caused the error
110
+ text (str, optional): Text that caused the error
111
+ Returns:
112
+ dict: Formatted error response
113
+ """
114
+ response = {"error": f"Embedding service error: {error}"}
115
+ if lang is not None:
116
+ response["lang"] = lang
117
+ if text is not None:
118
+ response["text"] = text
119
+ return response
120
+
121
+ def extract_embeddings(self, result: Dict[str, Any]) -> List[List[float]]:
122
+ """
123
+ Extract embeddings from API response, supporting both old and new formats.
124
+
125
+ Args:
126
+ result: API response dictionary
127
+
128
+ Returns:
129
+ List of embedding vectors (list of lists of floats)
130
+
131
+ Raises:
132
+ ValueError: If embeddings cannot be extracted from the response
133
+ """
134
+ # Handle direct embeddings field (old format compatibility)
135
+ if "embeddings" in result:
136
+ return result["embeddings"]
137
+
138
+ # Handle result wrapper
139
+ if "result" in result:
140
+ res = result["result"]
141
+
142
+ # Handle direct list in result (old format)
143
+ if isinstance(res, list):
144
+ return res
145
+
146
+ if isinstance(res, dict):
147
+ # Handle old format: result.embeddings
148
+ if "embeddings" in res:
149
+ return res["embeddings"]
150
+
151
+ # Handle old format: result.data.embeddings
152
+ if "data" in res and isinstance(res["data"], dict) and "embeddings" in res["data"]:
153
+ return res["data"]["embeddings"]
154
+
155
+ # Handle new format: result.data[].embedding
156
+ if "data" in res and isinstance(res["data"], list):
157
+ embeddings = []
158
+ for item in res["data"]:
159
+ if isinstance(item, dict) and "embedding" in item:
160
+ embeddings.append(item["embedding"])
161
+ else:
162
+ raise ValueError(f"Invalid item format in new API response: {item}")
163
+ return embeddings
164
+
165
+ raise ValueError(f"Cannot extract embeddings from response: {result}")
166
+
167
+ def extract_embedding_data(self, result: Dict[str, Any]) -> List[Dict[str, Any]]:
168
+ """
169
+ Extract full embedding data from API response (new format only).
170
+
171
+ Args:
172
+ result: API response dictionary
173
+
174
+ Returns:
175
+ List of dictionaries with 'body', 'embedding', 'tokens', and 'bm25_tokens' fields
176
+
177
+ Raises:
178
+ ValueError: If data cannot be extracted or is in old format
179
+ """
180
+ if "result" in result and isinstance(result["result"], dict):
181
+ res = result["result"]
182
+ if "data" in res and isinstance(res["data"], dict) and "results" in res["data"]:
183
+ # New format: result.data.results[]
184
+ results = res["data"]["results"]
185
+ if isinstance(results, list):
186
+ # Validate that all items have required fields
187
+ for i, item in enumerate(results):
188
+ if not isinstance(item, dict):
189
+ raise ValueError(f"Item {i} is not a dictionary: {item}")
190
+ if "body" not in item:
191
+ raise ValueError(f"Item {i} missing 'body' field: {item}")
192
+ if "embedding" not in item:
193
+ raise ValueError(f"Item {i} missing 'embedding' field: {item}")
194
+ if "tokens" not in item:
195
+ raise ValueError(f"Item {i} missing 'tokens' field: {item}")
196
+ if "bm25_tokens" not in item:
197
+ raise ValueError(f"Item {i} missing 'bm25_tokens' field: {item}")
198
+
199
+ return results
200
+
201
+ # Legacy support for old format: result.data[]
202
+ if "data" in res and isinstance(res["data"], list):
203
+ # Validate that all items have required fields
204
+ for i, item in enumerate(res["data"]):
205
+ if not isinstance(item, dict):
206
+ raise ValueError(f"Item {i} is not a dictionary: {item}")
207
+ if "body" not in item:
208
+ raise ValueError(f"Item {i} missing 'body' field: {item}")
209
+ if "embedding" not in item:
210
+ raise ValueError(f"Item {i} missing 'embedding' field: {item}")
211
+ # Old format had 'chunks' instead of 'tokens'
212
+ if "chunks" not in item and "tokens" not in item:
213
+ raise ValueError(f"Item {i} missing 'chunks' or 'tokens' field: {item}")
214
+
215
+ return res["data"]
216
+
217
+ raise ValueError(f"Cannot extract embedding data from response (new format required): {result}")
218
+
219
+ def extract_texts(self, result: Dict[str, Any]) -> List[str]:
220
+ """
221
+ Extract original texts from API response (new format only).
222
+
223
+ Args:
224
+ result: API response dictionary
225
+
226
+ Returns:
227
+ List of original text strings
228
+
229
+ Raises:
230
+ ValueError: If texts cannot be extracted or is in old format
231
+ """
232
+ data = self.extract_embedding_data(result)
233
+ return [item["body"] for item in data]
234
+
235
+ def extract_chunks(self, result: Dict[str, Any]) -> List[List[str]]:
236
+ """
237
+ Extract text chunks from API response (new format only).
238
+ Note: This method now extracts 'tokens' instead of 'chunks' for compatibility.
239
+
240
+ Args:
241
+ result: API response dictionary
242
+
243
+ Returns:
244
+ List of token lists for each text
245
+
246
+ Raises:
247
+ ValueError: If chunks cannot be extracted or is in old format
248
+ """
249
+ data = self.extract_embedding_data(result)
250
+ chunks = []
251
+ for item in data:
252
+ # New format uses 'tokens', old format used 'chunks'
253
+ if "tokens" in item:
254
+ chunks.append(item["tokens"])
255
+ elif "chunks" in item:
256
+ chunks.append(item["chunks"])
257
+ else:
258
+ raise ValueError(f"Item missing both 'tokens' and 'chunks' fields: {item}")
259
+ return chunks
260
+
261
+ def extract_tokens(self, result: Dict[str, Any]) -> List[List[str]]:
262
+ """
263
+ Extract tokens from API response (new format only).
264
+
265
+ Args:
266
+ result: API response dictionary
267
+
268
+ Returns:
269
+ List of token lists for each text
270
+
271
+ Raises:
272
+ ValueError: If tokens cannot be extracted or is in old format
273
+ """
274
+ data = self.extract_embedding_data(result)
275
+ return [item["tokens"] for item in data]
276
+
277
+ def extract_bm25_tokens(self, result: Dict[str, Any]) -> List[List[str]]:
278
+ """
279
+ Extract BM25 tokens from API response (new format only).
280
+
281
+ Args:
282
+ result: API response dictionary
283
+
284
+ Returns:
285
+ List of BM25 token lists for each text
286
+
287
+ Raises:
288
+ ValueError: If BM25 tokens cannot be extracted or is in old format
289
+ """
290
+ data = self.extract_embedding_data(result)
291
+ return [item["bm25_tokens"] for item in data]
292
+
293
+ async def __aenter__(self):
294
+ try:
295
+ # Create session with timeout configuration
296
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
297
+ self._session = aiohttp.ClientSession(timeout=timeout)
298
+ return self
299
+ except Exception as e:
300
+ raise EmbeddingServiceError(f"Failed to create HTTP session: {e}") from e
301
+
302
+ async def __aexit__(self, exc_type, exc, tb):
303
+ if self._session:
304
+ try:
305
+ await self._session.close()
306
+ except Exception as e:
307
+ raise EmbeddingServiceError(f"Failed to close HTTP session: {e}") from e
308
+ finally:
309
+ self._session = None
310
+
311
+ async def _parse_json_response(self, resp: aiohttp.ClientResponse) -> Dict[str, Any]:
312
+ """
313
+ Parse JSON response with proper error handling.
314
+
315
+ Args:
316
+ resp: aiohttp response object
317
+
318
+ Returns:
319
+ dict: Parsed JSON data
320
+
321
+ Raises:
322
+ EmbeddingServiceJSONError: If JSON parsing fails
323
+ """
324
+ try:
325
+ return await resp.json()
326
+ except json.JSONDecodeError as e:
327
+ try:
328
+ text = await resp.text()
329
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}. Response text: {text[:500]}...") from e
330
+ except Exception as text_error:
331
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}. Failed to get response text: {text_error}") from e
332
+ except UnicodeDecodeError as e:
333
+ raise EmbeddingServiceJSONError(f"Unicode decode error in response: {e}") from e
334
+ except Exception as e:
335
+ raise EmbeddingServiceJSONError(f"Unexpected error parsing JSON: {e}") from e
336
+
337
+ async def health(self, base_url: Optional[str] = None, port: Optional[int] = None) -> Dict[str, Any]:
338
+ """
339
+ Check the health of the service.
340
+ Args:
341
+ base_url (str, optional): Override base URL.
342
+ port (int, optional): Override port.
343
+ Returns:
344
+ dict: Health status and model info.
345
+ """
346
+ url = self._make_url("/health", base_url, port)
347
+ try:
348
+ async with self._session.get(url, timeout=self.timeout) as resp:
349
+ await self._raise_for_status(resp)
350
+ try:
351
+ data = await resp.json()
352
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as e:
353
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}") from e
354
+ if "error" in data:
355
+ raise EmbeddingServiceAPIError(data["error"])
356
+ return data
357
+ except EmbeddingServiceHTTPError:
358
+ raise
359
+ except EmbeddingServiceConnectionError:
360
+ raise
361
+ except EmbeddingServiceJSONError:
362
+ raise
363
+ except EmbeddingServiceTimeoutError:
364
+ raise
365
+ except aiohttp.ClientConnectionError as e:
366
+ raise EmbeddingServiceConnectionError(f"Connection error: {e}") from e
367
+ except aiohttp.ClientResponseError as e:
368
+ raise EmbeddingServiceHTTPError(e.status, e.message) from e
369
+ except asyncio.TimeoutError as e:
370
+ raise EmbeddingServiceTimeoutError(f"Request timeout: {e}") from e
371
+ except aiohttp.ServerTimeoutError as e:
372
+ raise EmbeddingServiceTimeoutError(f"Server timeout: {e}") from e
373
+ except aiohttp.ClientSSLError as e:
374
+ raise EmbeddingServiceConnectionError(f"SSL error: {e}") from e
375
+ except aiohttp.ClientOSError as e:
376
+ raise EmbeddingServiceConnectionError(f"OS error: {e}") from e
377
+ except Exception as e:
378
+ raise EmbeddingServiceError(f"Unexpected error: {e}") from e
379
+
380
+ async def get_openapi_schema(self, base_url: Optional[str] = None, port: Optional[int] = None) -> Dict[str, Any]:
381
+ """
382
+ Get the OpenAPI schema of the service.
383
+ Args:
384
+ base_url (str, optional): Override base URL.
385
+ port (int, optional): Override port.
386
+ Returns:
387
+ dict: OpenAPI schema.
388
+ """
389
+ url = self._make_url("/openapi.json", base_url, port)
390
+ try:
391
+ async with self._session.get(url, timeout=self.timeout) as resp:
392
+ await self._raise_for_status(resp)
393
+ try:
394
+ data = await resp.json()
395
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as e:
396
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}") from e
397
+ if "error" in data:
398
+ raise EmbeddingServiceAPIError(data["error"])
399
+ return data
400
+ except EmbeddingServiceHTTPError:
401
+ raise
402
+ except EmbeddingServiceConnectionError:
403
+ raise
404
+ except EmbeddingServiceJSONError:
405
+ raise
406
+ except EmbeddingServiceTimeoutError:
407
+ raise
408
+ except aiohttp.ClientConnectionError as e:
409
+ raise EmbeddingServiceConnectionError(f"Connection error: {e}") from e
410
+ except aiohttp.ClientResponseError as e:
411
+ raise EmbeddingServiceHTTPError(e.status, e.message) from e
412
+ except asyncio.TimeoutError as e:
413
+ raise EmbeddingServiceTimeoutError(f"Request timeout: {e}") from e
414
+ except aiohttp.ServerTimeoutError as e:
415
+ raise EmbeddingServiceTimeoutError(f"Server timeout: {e}") from e
416
+ except aiohttp.ClientSSLError as e:
417
+ raise EmbeddingServiceConnectionError(f"SSL error: {e}") from e
418
+ except aiohttp.ClientOSError as e:
419
+ raise EmbeddingServiceConnectionError(f"OS error: {e}") from e
420
+ except Exception as e:
421
+ raise EmbeddingServiceError(f"Unexpected error: {e}") from e
422
+
423
+ async def get_commands(self, base_url: Optional[str] = None, port: Optional[int] = None) -> Dict[str, Any]:
424
+ """
425
+ Get the list of available commands.
426
+ Args:
427
+ base_url (str, optional): Override base URL.
428
+ port (int, optional): Override port.
429
+ Returns:
430
+ dict: List of commands and their descriptions.
431
+ """
432
+ url = self._make_url("/api/commands", base_url, port)
433
+ try:
434
+ async with self._session.get(url, timeout=self.timeout) as resp:
435
+ await self._raise_for_status(resp)
436
+ try:
437
+ data = await resp.json()
438
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as e:
439
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}") from e
440
+ if "error" in data:
441
+ raise EmbeddingServiceAPIError(data["error"])
442
+ return data
443
+ except EmbeddingServiceHTTPError:
444
+ raise
445
+ except EmbeddingServiceConnectionError:
446
+ raise
447
+ except EmbeddingServiceJSONError:
448
+ raise
449
+ except EmbeddingServiceTimeoutError:
450
+ raise
451
+ except aiohttp.ClientConnectionError as e:
452
+ raise EmbeddingServiceConnectionError(f"Connection error: {e}") from e
453
+ except aiohttp.ClientResponseError as e:
454
+ raise EmbeddingServiceHTTPError(e.status, e.message) from e
455
+ except asyncio.TimeoutError as e:
456
+ raise EmbeddingServiceTimeoutError(f"Request timeout: {e}") from e
457
+ except aiohttp.ServerTimeoutError as e:
458
+ raise EmbeddingServiceTimeoutError(f"Server timeout: {e}") from e
459
+ except aiohttp.ClientSSLError as e:
460
+ raise EmbeddingServiceConnectionError(f"SSL error: {e}") from e
461
+ except aiohttp.ClientOSError as e:
462
+ raise EmbeddingServiceConnectionError(f"OS error: {e}") from e
463
+ except Exception as e:
464
+ raise EmbeddingServiceError(f"Unexpected error: {e}") from e
465
+
466
+ def _validate_texts(self, texts: List[str]) -> None:
467
+ """
468
+ Validate input texts before sending to the API.
469
+ Args:
470
+ texts (List[str]): List of texts to validate
471
+ Raises:
472
+ EmbeddingServiceAPIError: If texts are invalid
473
+ """
474
+ if not texts:
475
+ raise EmbeddingServiceAPIError({
476
+ "code": -32602,
477
+ "message": "Empty texts list provided"
478
+ })
479
+
480
+ invalid_texts = []
481
+ for i, text in enumerate(texts):
482
+ if not isinstance(text, str):
483
+ invalid_texts.append(f"Text at index {i} is not a string")
484
+ continue
485
+ if not text or not text.strip():
486
+ invalid_texts.append(f"Text at index {i} is empty or contains only whitespace")
487
+ elif len(text.strip()) < 2: # Минимальная длина текста
488
+ invalid_texts.append(f"Text at index {i} is too short (minimum 2 characters)")
489
+
490
+ if invalid_texts:
491
+ raise EmbeddingServiceAPIError({
492
+ "code": -32602,
493
+ "message": "Invalid input texts",
494
+ "details": invalid_texts
495
+ })
496
+
497
+ async def cmd(self, command: str, params: Optional[Dict[str, Any]] = None, base_url: Optional[str] = None, port: Optional[int] = None) -> Dict[str, Any]:
498
+ """
499
+ Execute a command via JSON-RPC protocol.
500
+ Args:
501
+ command (str): Command to execute (embed, models, health, help, config).
502
+ params (dict, optional): Parameters for the command.
503
+ base_url (str, optional): Override base URL.
504
+ port (int, optional): Override port.
505
+ Returns:
506
+ dict: Command execution result or error response in format:
507
+ {
508
+ "error": {
509
+ "code": <код ошибки>,
510
+ "message": <сообщение об ошибке>,
511
+ "details": <опциональные детали ошибки>
512
+ }
513
+ }
514
+ или
515
+ {
516
+ "result": {
517
+ "success": true,
518
+ "data": {
519
+ "embeddings": [[...], ...]
520
+ }
521
+ }
522
+ }
523
+ """
524
+ if not command:
525
+ raise EmbeddingServiceAPIError({
526
+ "code": -32602,
527
+ "message": "Command is required"
528
+ })
529
+
530
+ # Валидация текстов для команды embed
531
+ if command == "embed" and params and "texts" in params:
532
+ self._validate_texts(params["texts"])
533
+
534
+ logger = logging.getLogger('EmbeddingServiceAsyncClient.cmd')
535
+ url = self._make_url("/cmd", base_url, port)
536
+ payload = {"command": command}
537
+ if params is not None:
538
+ payload["params"] = params
539
+ logger.info(f"Sending embedding command: url={url}, payload={payload}")
540
+ try:
541
+ async with self._session.post(url, json=payload, timeout=self.timeout) as resp:
542
+ logger.info(f"Embedding service HTTP status: {resp.status}")
543
+ await self._raise_for_status(resp)
544
+ try:
545
+ resp_json = await resp.json()
546
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as e:
547
+ raise EmbeddingServiceJSONError(f"Invalid JSON response: {e}") from e
548
+ logger.info(f"Embedding service response: {str(resp_json)[:300]}")
549
+ # Обработка ошибок API
550
+ if "error" in resp_json:
551
+ raise EmbeddingServiceAPIError(resp_json["error"])
552
+ if "result" in resp_json:
553
+ result = resp_json["result"]
554
+ if isinstance(result, dict) and (result.get("success") is False or "error" in result):
555
+ raise EmbeddingServiceAPIError(result.get("error", result))
556
+ return resp_json
557
+ except EmbeddingServiceAPIError:
558
+ raise
559
+ except EmbeddingServiceHTTPError:
560
+ raise
561
+ except EmbeddingServiceConnectionError:
562
+ raise
563
+ except EmbeddingServiceJSONError:
564
+ raise
565
+ except EmbeddingServiceTimeoutError:
566
+ raise
567
+ except aiohttp.ServerTimeoutError as e:
568
+ raise EmbeddingServiceTimeoutError(f"Server timeout: {e}") from e
569
+ except aiohttp.ClientConnectionError as e:
570
+ raise EmbeddingServiceConnectionError(f"Connection error: {e}") from e
571
+ except aiohttp.ClientResponseError as e:
572
+ raise EmbeddingServiceHTTPError(e.status, e.message) from e
573
+ except asyncio.TimeoutError as e:
574
+ raise EmbeddingServiceTimeoutError(f"Request timeout: {e}") from e
575
+ except aiohttp.ClientSSLError as e:
576
+ raise EmbeddingServiceConnectionError(f"SSL error: {e}") from e
577
+ except aiohttp.ClientOSError as e:
578
+ raise EmbeddingServiceConnectionError(f"OS error: {e}") from e
579
+ except Exception as e:
580
+ logger.error(f"Error in embedding cmd: {e}", exc_info=True)
581
+ raise EmbeddingServiceError(f"Unexpected error: {e}") from e
582
+
583
+ async def _raise_for_status(self, resp: aiohttp.ClientResponse):
584
+ try:
585
+ resp.raise_for_status()
586
+ except aiohttp.ClientResponseError as e:
587
+ raise EmbeddingServiceHTTPError(e.status, e.message) from e
588
+
589
+ async def close(self) -> None:
590
+ """
591
+ Close the underlying HTTP session explicitly.
592
+
593
+ This method allows the user to manually close the aiohttp.ClientSession used by the client.
594
+ It is safe to call multiple times; if the session is already closed or was never opened, nothing happens.
595
+
596
+ Raises:
597
+ EmbeddingServiceError: If closing the session fails.
598
+ """
599
+ if self._session:
600
+ try:
601
+ await self._session.close()
602
+ except Exception as e:
603
+ raise EmbeddingServiceError(f"Failed to close HTTP session: {e}") from e
604
+ finally:
605
+ self._session = None
606
+
607
+ # TODO: Add methods for /cmd, /api/commands, etc.