vectorizer-sdk 3.0.0__tar.gz → 3.2.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.
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/PKG-INFO +55 -6
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/README.md +54 -5
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/pyproject.toml +110 -110
- vectorizer_sdk-3.2.0/tests/test_retry_after_parse.py +51 -0
- vectorizer_sdk-3.2.0/utils/http_client.py +212 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/_base.py +19 -1
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/auth.py +27 -1
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/client.py +10 -1
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/vectors.py +47 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/PKG-INFO +55 -6
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/SOURCES.txt +1 -0
- vectorizer_sdk-3.0.0/utils/http_client.py +0 -145
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/LICENSE +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/__init__.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/_codec.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/async_client.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/commands.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/endpoint.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/pool.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/sync_client.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/rpc/types.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/setup.cfg +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_client_integration.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_discovery.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_exceptions.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_file_operations.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_file_upload.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_graph.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_http_client.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_intelligent_search.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_mock_transport.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_models.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_qdrant_advanced.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_routing.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_sdk_comprehensive.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_simple.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_umicp.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/tests/test_validation.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/utils/__init__.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/utils/transport.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/utils/umicp_client.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/utils/validation.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/__init__.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/admin.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/collections.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/graph.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer/search.py +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/dependency_links.txt +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/entry_points.txt +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/requires.txt +0 -0
- {vectorizer_sdk-3.0.0 → vectorizer_sdk-3.2.0}/vectorizer_sdk.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vectorizer_sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Python SDK for Vectorizer - Semantic search, vector operations, and the VectorizerRPC binary transport (default in v3.x)
|
|
5
5
|
Author-email: HiveLLM Team <team@hivellm.org>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -58,9 +58,38 @@ Dynamic: license-file
|
|
|
58
58
|
A comprehensive Python SDK for the Vectorizer semantic search service.
|
|
59
59
|
|
|
60
60
|
**Package**: `vectorizer_sdk` (PEP 625 compliant)
|
|
61
|
-
**Version**: 3.
|
|
61
|
+
**Version**: 3.2.0
|
|
62
62
|
**PyPI**: https://pypi.org/project/vectorizer-sdk/
|
|
63
63
|
|
|
64
|
+
## v3.2 — backpressure-aware client (HTTP 429 + `Retry-After`)
|
|
65
|
+
|
|
66
|
+
The REST `VectorizerClient` honors server-side bulk-upsert
|
|
67
|
+
backpressure shipped in Vectorizer 3.2.0
|
|
68
|
+
([#263](https://github.com/hivellm/vectorizer/issues/263)). On HTTP
|
|
69
|
+
`429 Too Many Requests` the client parses `Retry-After` (seconds
|
|
70
|
+
form, 1 s default, 30 s cap), sleeps, and retries up to 3 times
|
|
71
|
+
before raising a typed `RateLimitError`. Pre-3.2.0 clients bounced
|
|
72
|
+
429s into a generic 5xx and lost the retry budget. Identical
|
|
73
|
+
semantics ship in every first-party SDK (Rust, Python, TypeScript,
|
|
74
|
+
Go, C#) — see `tests/test_retry_after_parse.py`.
|
|
75
|
+
|
|
76
|
+
## v3.1 — `/insert_vectors` + stable client-id upserts
|
|
77
|
+
|
|
78
|
+
- `insert_vectors(collection, vectors, public_key=None)` — bulk-
|
|
79
|
+
insert pre-computed embeddings with caller-supplied vector ids.
|
|
80
|
+
Skips the embedding pipeline entirely.
|
|
81
|
+
- `insert` / `insert_texts`: the request `id` is now used verbatim
|
|
82
|
+
as the stored `Vector.id` (non-chunked) or as `<id>#<chunk_index>`
|
|
83
|
+
(chunked). Re-running the same payload upserts in place instead
|
|
84
|
+
of duplicating.
|
|
85
|
+
- Chunked vectors expose a flat payload layout (`{content,
|
|
86
|
+
file_path, chunk_index, parent_id, ...user_metadata}`). Legacy
|
|
87
|
+
nested payloads from ≤ 3.0.x stay readable during the deprecation
|
|
88
|
+
window.
|
|
89
|
+
|
|
90
|
+
Client-id contract: non-empty, length ≤ 256, no leading/trailing
|
|
91
|
+
whitespace, must not contain `#`.
|
|
92
|
+
|
|
64
93
|
## v3.0 — VectorizerRPC is the default transport
|
|
65
94
|
|
|
66
95
|
Starting with v3.0, the recommended transport is **VectorizerRPC**: a
|
|
@@ -79,6 +108,8 @@ import vectorizer_sdk
|
|
|
79
108
|
|
|
80
109
|
async def main():
|
|
81
110
|
client = await vectorizer_sdk.connect_async("vectorizer://127.0.0.1:15503")
|
|
111
|
+
# `hello` and `search_basic` are RPC-only (not available on the legacy
|
|
112
|
+
# REST `VectorizerClient`).
|
|
82
113
|
await client.hello(vectorizer_sdk.HelloPayload(client_name="my-app"))
|
|
83
114
|
print(await client.list_collections())
|
|
84
115
|
hits = await client.search_basic("docs", "vector database", limit=5)
|
|
@@ -140,7 +171,7 @@ for a runnable end-to-end example.
|
|
|
140
171
|
pip install vectorizer-sdk
|
|
141
172
|
|
|
142
173
|
# Or specific version
|
|
143
|
-
pip install vectorizer-sdk==3.
|
|
174
|
+
pip install vectorizer-sdk==3.2.0
|
|
144
175
|
```
|
|
145
176
|
|
|
146
177
|
## Package Layout (v3.x)
|
|
@@ -477,6 +508,11 @@ related = await client.get_related_files(
|
|
|
477
508
|
|
|
478
509
|
### Summarization Operations
|
|
479
510
|
|
|
511
|
+
> WARNING: The `/summarize/*` REST endpoints are documented but not yet wired
|
|
512
|
+
> server-side (see `DOC_GAP_ANALYSIS`). The SDK methods below
|
|
513
|
+
> (`summarize_text`, `summarize_context`) will fail until server wiring is
|
|
514
|
+
> complete.
|
|
515
|
+
|
|
480
516
|
#### Summarize Text
|
|
481
517
|
Summarize text using various methods:
|
|
482
518
|
|
|
@@ -509,6 +545,11 @@ summary = await client.summarize_context(
|
|
|
509
545
|
|
|
510
546
|
### Workspace Management
|
|
511
547
|
|
|
548
|
+
> WARNING: `add_workspace`, `list_workspaces`, and `remove_workspace` are
|
|
549
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
550
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
551
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
552
|
+
|
|
512
553
|
#### Add Workspace
|
|
513
554
|
Add a new workspace:
|
|
514
555
|
|
|
@@ -537,6 +578,11 @@ await client.remove_workspace(
|
|
|
537
578
|
|
|
538
579
|
### Backup Operations
|
|
539
580
|
|
|
581
|
+
> WARNING: `create_backup`, `list_backups`, and `restore_backup` are
|
|
582
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
583
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
584
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
585
|
+
|
|
540
586
|
#### Create Backup
|
|
541
587
|
Create a backup of collections:
|
|
542
588
|
|
|
@@ -664,8 +710,11 @@ await client.create_collection("documents", dimension=768)
|
|
|
664
710
|
await client.insert_texts("documents", [
|
|
665
711
|
{"id": "doc1", "text": "Sample document", "metadata": {"source": "api"}}
|
|
666
712
|
])
|
|
667
|
-
|
|
668
|
-
await client.
|
|
713
|
+
# Update-via-reinsert: re-call `insert_texts` with the same id to replace the record.
|
|
714
|
+
await client.insert_texts("documents", [
|
|
715
|
+
{"id": "doc1", "text": "Sample document (updated)", "metadata": {"updated": True}}
|
|
716
|
+
])
|
|
717
|
+
await client.delete_vectors("documents", ["doc1"])
|
|
669
718
|
|
|
670
719
|
# Reads automatically go to replicas (load balanced)
|
|
671
720
|
results = await client.search_vectors("documents", query="sample", limit=10)
|
|
@@ -702,7 +751,7 @@ The SDK automatically classifies operations:
|
|
|
702
751
|
|
|
703
752
|
| Operation Type | Routed To | Methods |
|
|
704
753
|
|---------------|-----------|---------|
|
|
705
|
-
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `
|
|
754
|
+
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `delete_vectors`, `create_collection`, `delete_collection` |
|
|
706
755
|
| **Reads** | Based on `read_preference` | `search_vectors`, `get_vector`, `list_collections`, `intelligent_search`, `semantic_search`, `hybrid_search` |
|
|
707
756
|
|
|
708
757
|
#### Standalone Mode (Single Node)
|
|
@@ -7,9 +7,38 @@
|
|
|
7
7
|
A comprehensive Python SDK for the Vectorizer semantic search service.
|
|
8
8
|
|
|
9
9
|
**Package**: `vectorizer_sdk` (PEP 625 compliant)
|
|
10
|
-
**Version**: 3.
|
|
10
|
+
**Version**: 3.2.0
|
|
11
11
|
**PyPI**: https://pypi.org/project/vectorizer-sdk/
|
|
12
12
|
|
|
13
|
+
## v3.2 — backpressure-aware client (HTTP 429 + `Retry-After`)
|
|
14
|
+
|
|
15
|
+
The REST `VectorizerClient` honors server-side bulk-upsert
|
|
16
|
+
backpressure shipped in Vectorizer 3.2.0
|
|
17
|
+
([#263](https://github.com/hivellm/vectorizer/issues/263)). On HTTP
|
|
18
|
+
`429 Too Many Requests` the client parses `Retry-After` (seconds
|
|
19
|
+
form, 1 s default, 30 s cap), sleeps, and retries up to 3 times
|
|
20
|
+
before raising a typed `RateLimitError`. Pre-3.2.0 clients bounced
|
|
21
|
+
429s into a generic 5xx and lost the retry budget. Identical
|
|
22
|
+
semantics ship in every first-party SDK (Rust, Python, TypeScript,
|
|
23
|
+
Go, C#) — see `tests/test_retry_after_parse.py`.
|
|
24
|
+
|
|
25
|
+
## v3.1 — `/insert_vectors` + stable client-id upserts
|
|
26
|
+
|
|
27
|
+
- `insert_vectors(collection, vectors, public_key=None)` — bulk-
|
|
28
|
+
insert pre-computed embeddings with caller-supplied vector ids.
|
|
29
|
+
Skips the embedding pipeline entirely.
|
|
30
|
+
- `insert` / `insert_texts`: the request `id` is now used verbatim
|
|
31
|
+
as the stored `Vector.id` (non-chunked) or as `<id>#<chunk_index>`
|
|
32
|
+
(chunked). Re-running the same payload upserts in place instead
|
|
33
|
+
of duplicating.
|
|
34
|
+
- Chunked vectors expose a flat payload layout (`{content,
|
|
35
|
+
file_path, chunk_index, parent_id, ...user_metadata}`). Legacy
|
|
36
|
+
nested payloads from ≤ 3.0.x stay readable during the deprecation
|
|
37
|
+
window.
|
|
38
|
+
|
|
39
|
+
Client-id contract: non-empty, length ≤ 256, no leading/trailing
|
|
40
|
+
whitespace, must not contain `#`.
|
|
41
|
+
|
|
13
42
|
## v3.0 — VectorizerRPC is the default transport
|
|
14
43
|
|
|
15
44
|
Starting with v3.0, the recommended transport is **VectorizerRPC**: a
|
|
@@ -28,6 +57,8 @@ import vectorizer_sdk
|
|
|
28
57
|
|
|
29
58
|
async def main():
|
|
30
59
|
client = await vectorizer_sdk.connect_async("vectorizer://127.0.0.1:15503")
|
|
60
|
+
# `hello` and `search_basic` are RPC-only (not available on the legacy
|
|
61
|
+
# REST `VectorizerClient`).
|
|
31
62
|
await client.hello(vectorizer_sdk.HelloPayload(client_name="my-app"))
|
|
32
63
|
print(await client.list_collections())
|
|
33
64
|
hits = await client.search_basic("docs", "vector database", limit=5)
|
|
@@ -89,7 +120,7 @@ for a runnable end-to-end example.
|
|
|
89
120
|
pip install vectorizer-sdk
|
|
90
121
|
|
|
91
122
|
# Or specific version
|
|
92
|
-
pip install vectorizer-sdk==3.
|
|
123
|
+
pip install vectorizer-sdk==3.2.0
|
|
93
124
|
```
|
|
94
125
|
|
|
95
126
|
## Package Layout (v3.x)
|
|
@@ -426,6 +457,11 @@ related = await client.get_related_files(
|
|
|
426
457
|
|
|
427
458
|
### Summarization Operations
|
|
428
459
|
|
|
460
|
+
> WARNING: The `/summarize/*` REST endpoints are documented but not yet wired
|
|
461
|
+
> server-side (see `DOC_GAP_ANALYSIS`). The SDK methods below
|
|
462
|
+
> (`summarize_text`, `summarize_context`) will fail until server wiring is
|
|
463
|
+
> complete.
|
|
464
|
+
|
|
429
465
|
#### Summarize Text
|
|
430
466
|
Summarize text using various methods:
|
|
431
467
|
|
|
@@ -458,6 +494,11 @@ summary = await client.summarize_context(
|
|
|
458
494
|
|
|
459
495
|
### Workspace Management
|
|
460
496
|
|
|
497
|
+
> WARNING: `add_workspace`, `list_workspaces`, and `remove_workspace` are
|
|
498
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
499
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
500
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
501
|
+
|
|
461
502
|
#### Add Workspace
|
|
462
503
|
Add a new workspace:
|
|
463
504
|
|
|
@@ -486,6 +527,11 @@ await client.remove_workspace(
|
|
|
486
527
|
|
|
487
528
|
### Backup Operations
|
|
488
529
|
|
|
530
|
+
> WARNING: `create_backup`, `list_backups`, and `restore_backup` are
|
|
531
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
532
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
533
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
534
|
+
|
|
489
535
|
#### Create Backup
|
|
490
536
|
Create a backup of collections:
|
|
491
537
|
|
|
@@ -613,8 +659,11 @@ await client.create_collection("documents", dimension=768)
|
|
|
613
659
|
await client.insert_texts("documents", [
|
|
614
660
|
{"id": "doc1", "text": "Sample document", "metadata": {"source": "api"}}
|
|
615
661
|
])
|
|
616
|
-
|
|
617
|
-
await client.
|
|
662
|
+
# Update-via-reinsert: re-call `insert_texts` with the same id to replace the record.
|
|
663
|
+
await client.insert_texts("documents", [
|
|
664
|
+
{"id": "doc1", "text": "Sample document (updated)", "metadata": {"updated": True}}
|
|
665
|
+
])
|
|
666
|
+
await client.delete_vectors("documents", ["doc1"])
|
|
618
667
|
|
|
619
668
|
# Reads automatically go to replicas (load balanced)
|
|
620
669
|
results = await client.search_vectors("documents", query="sample", limit=10)
|
|
@@ -651,7 +700,7 @@ The SDK automatically classifies operations:
|
|
|
651
700
|
|
|
652
701
|
| Operation Type | Routed To | Methods |
|
|
653
702
|
|---------------|-----------|---------|
|
|
654
|
-
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `
|
|
703
|
+
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `delete_vectors`, `create_collection`, `delete_collection` |
|
|
655
704
|
| **Reads** | Based on `read_preference` | `search_vectors`, `get_vector`, `list_collections`, `intelligent_search`, `semantic_search`, `hybrid_search` |
|
|
656
705
|
|
|
657
706
|
#### Standalone Mode (Single Node)
|
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "vectorizer_sdk"
|
|
7
|
-
version = "3.
|
|
8
|
-
description = "Python SDK for Vectorizer - Semantic search, vector operations, and the VectorizerRPC binary transport (default in v3.x)"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.8"
|
|
11
|
-
license = {text = "Apache-2.0"}
|
|
12
|
-
authors = [
|
|
13
|
-
{name = "HiveLLM Team", email = "team@hivellm.org"}
|
|
14
|
-
]
|
|
15
|
-
keywords = [
|
|
16
|
-
"vectorizer",
|
|
17
|
-
"semantic-search",
|
|
18
|
-
"embeddings",
|
|
19
|
-
"machine-learning",
|
|
20
|
-
"ai",
|
|
21
|
-
"search",
|
|
22
|
-
"vectors",
|
|
23
|
-
"similarity",
|
|
24
|
-
"hivellm",
|
|
25
|
-
"umicp"
|
|
26
|
-
]
|
|
27
|
-
classifiers = [
|
|
28
|
-
"Development Status :: 5 - Production/Stable",
|
|
29
|
-
"Intended Audience :: Developers",
|
|
30
|
-
"Operating System :: OS Independent",
|
|
31
|
-
"Programming Language :: Python :: 3",
|
|
32
|
-
"Programming Language :: Python :: 3.8",
|
|
33
|
-
"Programming Language :: Python :: 3.9",
|
|
34
|
-
"Programming Language :: Python :: 3.10",
|
|
35
|
-
"Programming Language :: Python :: 3.11",
|
|
36
|
-
"Programming Language :: Python :: 3.12",
|
|
37
|
-
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
-
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
39
|
-
"Topic :: Text Processing :: Indexing",
|
|
40
|
-
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
dependencies = [
|
|
44
|
-
"aiohttp>=3.10.0",
|
|
45
|
-
"msgpack>=1.1.0",
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
[project.optional-dependencies]
|
|
49
|
-
umicp = [
|
|
50
|
-
"umicp-python>=0.1.3",
|
|
51
|
-
]
|
|
52
|
-
dev = [
|
|
53
|
-
"pytest>=8.0.0",
|
|
54
|
-
"pytest-asyncio>=0.24.0",
|
|
55
|
-
"pytest-cov>=5.0.0",
|
|
56
|
-
"black>=25.0.0",
|
|
57
|
-
"isort>=5.13.0",
|
|
58
|
-
"flake8>=7.0.0",
|
|
59
|
-
"mypy>=1.11.0",
|
|
60
|
-
"pre-commit>=4.0.0",
|
|
61
|
-
]
|
|
62
|
-
docs = [
|
|
63
|
-
"sphinx>=7.0.0",
|
|
64
|
-
"sphinx-rtd-theme>=2.0.0",
|
|
65
|
-
"myst-parser>=3.0.0",
|
|
66
|
-
]
|
|
67
|
-
test = [
|
|
68
|
-
"pytest>=8.0.0",
|
|
69
|
-
"pytest-asyncio>=0.24.0",
|
|
70
|
-
"pytest-cov>=5.0.0",
|
|
71
|
-
"httpx>=0.27.0",
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
[project.urls]
|
|
75
|
-
Homepage = "https://github.com/hivellm/vectorizer"
|
|
76
|
-
Documentation = "https://github.com/hivellm/vectorizer/tree/main/docs"
|
|
77
|
-
Repository = "https://github.com/hivellm/vectorizer"
|
|
78
|
-
Issues = "https://github.com/hivellm/vectorizer/issues"
|
|
79
|
-
|
|
80
|
-
[project.scripts]
|
|
81
|
-
vectorizer-cli = "cli:main"
|
|
82
|
-
|
|
83
|
-
[tool.setuptools.packages.find]
|
|
84
|
-
include = ["*"]
|
|
85
|
-
exclude = ["tests*", "examples*", "docs*"]
|
|
86
|
-
|
|
87
|
-
[tool.setuptools.package-data]
|
|
88
|
-
"*" = ["py.typed"]
|
|
89
|
-
|
|
90
|
-
[tool.pytest.ini_options]
|
|
91
|
-
asyncio_mode = "auto"
|
|
92
|
-
testpaths = ["tests"]
|
|
93
|
-
python_files = ["test_*.py"]
|
|
94
|
-
python_classes = ["Test*"]
|
|
95
|
-
python_functions = ["test_*"]
|
|
96
|
-
|
|
97
|
-
[tool.black]
|
|
98
|
-
line-length = 100
|
|
99
|
-
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
|
100
|
-
|
|
101
|
-
[tool.isort]
|
|
102
|
-
profile = "black"
|
|
103
|
-
line_length = 100
|
|
104
|
-
|
|
105
|
-
[tool.mypy]
|
|
106
|
-
python_version = "3.8"
|
|
107
|
-
warn_return_any = true
|
|
108
|
-
warn_unused_configs = true
|
|
109
|
-
disallow_untyped_defs = false
|
|
110
|
-
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vectorizer_sdk"
|
|
7
|
+
version = "3.2.0"
|
|
8
|
+
description = "Python SDK for Vectorizer - Semantic search, vector operations, and the VectorizerRPC binary transport (default in v3.x)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "HiveLLM Team", email = "team@hivellm.org"}
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"vectorizer",
|
|
17
|
+
"semantic-search",
|
|
18
|
+
"embeddings",
|
|
19
|
+
"machine-learning",
|
|
20
|
+
"ai",
|
|
21
|
+
"search",
|
|
22
|
+
"vectors",
|
|
23
|
+
"similarity",
|
|
24
|
+
"hivellm",
|
|
25
|
+
"umicp"
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 5 - Production/Stable",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.8",
|
|
33
|
+
"Programming Language :: Python :: 3.9",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
39
|
+
"Topic :: Text Processing :: Indexing",
|
|
40
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
dependencies = [
|
|
44
|
+
"aiohttp>=3.10.0",
|
|
45
|
+
"msgpack>=1.1.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.optional-dependencies]
|
|
49
|
+
umicp = [
|
|
50
|
+
"umicp-python>=0.1.3",
|
|
51
|
+
]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=8.0.0",
|
|
54
|
+
"pytest-asyncio>=0.24.0",
|
|
55
|
+
"pytest-cov>=5.0.0",
|
|
56
|
+
"black>=25.0.0",
|
|
57
|
+
"isort>=5.13.0",
|
|
58
|
+
"flake8>=7.0.0",
|
|
59
|
+
"mypy>=1.11.0",
|
|
60
|
+
"pre-commit>=4.0.0",
|
|
61
|
+
]
|
|
62
|
+
docs = [
|
|
63
|
+
"sphinx>=7.0.0",
|
|
64
|
+
"sphinx-rtd-theme>=2.0.0",
|
|
65
|
+
"myst-parser>=3.0.0",
|
|
66
|
+
]
|
|
67
|
+
test = [
|
|
68
|
+
"pytest>=8.0.0",
|
|
69
|
+
"pytest-asyncio>=0.24.0",
|
|
70
|
+
"pytest-cov>=5.0.0",
|
|
71
|
+
"httpx>=0.27.0",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[project.urls]
|
|
75
|
+
Homepage = "https://github.com/hivellm/vectorizer"
|
|
76
|
+
Documentation = "https://github.com/hivellm/vectorizer/tree/main/docs"
|
|
77
|
+
Repository = "https://github.com/hivellm/vectorizer"
|
|
78
|
+
Issues = "https://github.com/hivellm/vectorizer/issues"
|
|
79
|
+
|
|
80
|
+
[project.scripts]
|
|
81
|
+
vectorizer-cli = "cli:main"
|
|
82
|
+
|
|
83
|
+
[tool.setuptools.packages.find]
|
|
84
|
+
include = ["*"]
|
|
85
|
+
exclude = ["tests*", "examples*", "docs*"]
|
|
86
|
+
|
|
87
|
+
[tool.setuptools.package-data]
|
|
88
|
+
"*" = ["py.typed"]
|
|
89
|
+
|
|
90
|
+
[tool.pytest.ini_options]
|
|
91
|
+
asyncio_mode = "auto"
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
python_files = ["test_*.py"]
|
|
94
|
+
python_classes = ["Test*"]
|
|
95
|
+
python_functions = ["test_*"]
|
|
96
|
+
|
|
97
|
+
[tool.black]
|
|
98
|
+
line-length = 100
|
|
99
|
+
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
|
100
|
+
|
|
101
|
+
[tool.isort]
|
|
102
|
+
profile = "black"
|
|
103
|
+
line_length = 100
|
|
104
|
+
|
|
105
|
+
[tool.mypy]
|
|
106
|
+
python_version = "3.8"
|
|
107
|
+
warn_return_any = true
|
|
108
|
+
warn_unused_configs = true
|
|
109
|
+
disallow_untyped_defs = false
|
|
110
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Retry-After header parser tests for the Python SDK (issue #263, phase9 §7).
|
|
2
|
+
|
|
3
|
+
The full retry loop is exercised end-to-end at the server level by
|
|
4
|
+
``crates/vectorizer-server/tests/backpressure_429.rs``; here we only
|
|
5
|
+
lock in the value-parsing edges that determine how aggressively the
|
|
6
|
+
SDK backs off.
|
|
7
|
+
|
|
8
|
+
These constants are kept in sync with ``utils/http_client.py``:
|
|
9
|
+
|
|
10
|
+
- missing/unparseable header -> 1 s default
|
|
11
|
+
- ``Retry-After: 0`` -> 1 s default (never busy-loop)
|
|
12
|
+
- capped at 30 s so a misconfigured server cannot pin the client
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import unittest
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
20
|
+
|
|
21
|
+
from utils.http_client import _parse_retry_after # type: ignore[import-not-found]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestRetryAfterParse(unittest.TestCase):
|
|
25
|
+
def test_missing_header_returns_default(self) -> None:
|
|
26
|
+
self.assertEqual(_parse_retry_after(None), 1)
|
|
27
|
+
|
|
28
|
+
def test_empty_or_whitespace_returns_default(self) -> None:
|
|
29
|
+
self.assertEqual(_parse_retry_after(""), 1)
|
|
30
|
+
self.assertEqual(_parse_retry_after(" "), 1)
|
|
31
|
+
|
|
32
|
+
def test_zero_returns_default_to_avoid_busy_loop(self) -> None:
|
|
33
|
+
self.assertEqual(_parse_retry_after("0"), 1)
|
|
34
|
+
|
|
35
|
+
def test_unparseable_string_returns_default(self) -> None:
|
|
36
|
+
self.assertEqual(_parse_retry_after("not-a-number"), 1)
|
|
37
|
+
|
|
38
|
+
def test_small_values_pass_through_verbatim(self) -> None:
|
|
39
|
+
self.assertEqual(_parse_retry_after("3"), 3)
|
|
40
|
+
self.assertEqual(_parse_retry_after("7"), 7)
|
|
41
|
+
self.assertEqual(_parse_retry_after(" 5 "), 5)
|
|
42
|
+
|
|
43
|
+
def test_large_values_are_capped_at_30s(self) -> None:
|
|
44
|
+
# If this assertion ever flips, audit _RETRY_AFTER_MAX_SECONDS
|
|
45
|
+
# in utils/http_client.py first.
|
|
46
|
+
self.assertEqual(_parse_retry_after("3600"), 30)
|
|
47
|
+
self.assertEqual(_parse_retry_after("31"), 30)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client utility for making API requests using aiohttp.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
import aiohttp
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from ..exceptions import (
|
|
12
|
+
NetworkError,
|
|
13
|
+
ServerError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
)
|
|
17
|
+
except ImportError:
|
|
18
|
+
from exceptions import (
|
|
19
|
+
NetworkError,
|
|
20
|
+
ServerError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Issue #263: cap Retry-After respect at this many seconds so a
|
|
26
|
+
# misconfigured server can't pin a client into a half-hour sleep.
|
|
27
|
+
_RETRY_AFTER_MAX_SECONDS = 30
|
|
28
|
+
# Floor for parsed Retry-After so a `0` or missing header still yields
|
|
29
|
+
# a noticeable backoff rather than busy-looping the server.
|
|
30
|
+
_RETRY_AFTER_DEFAULT_SECONDS = 1
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HTTPClient:
|
|
36
|
+
"""HTTP transport client."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
base_url: str = "http://localhost:15002",
|
|
41
|
+
api_key: Optional[str] = None,
|
|
42
|
+
timeout: int = 30,
|
|
43
|
+
max_retries: int = 3
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Initialize HTTP client.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
base_url: Base URL for HTTP API
|
|
50
|
+
api_key: API key for authentication
|
|
51
|
+
timeout: Request timeout in seconds
|
|
52
|
+
max_retries: Maximum number of retry attempts
|
|
53
|
+
"""
|
|
54
|
+
self.base_url = base_url.rstrip('/')
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self.max_retries = max_retries
|
|
58
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
59
|
+
|
|
60
|
+
async def _ensure_session(self):
|
|
61
|
+
"""Ensure aiohttp session is created."""
|
|
62
|
+
if self._session is None or self._session.closed:
|
|
63
|
+
headers = {"Content-Type": "application/json"}
|
|
64
|
+
if self.api_key:
|
|
65
|
+
# JWT shape → `Authorization: Bearer`; anything else
|
|
66
|
+
# (raw API keys from `POST /auth/keys`) → `X-API-Key`.
|
|
67
|
+
# Server middleware won't fall back Bearer → api_key,
|
|
68
|
+
# so routing has to happen client-side.
|
|
69
|
+
parts = self.api_key.split(".")
|
|
70
|
+
if len(parts) == 3 and all(p for p in parts):
|
|
71
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
72
|
+
else:
|
|
73
|
+
headers["X-API-Key"] = self.api_key
|
|
74
|
+
|
|
75
|
+
timeout_config = aiohttp.ClientTimeout(total=self.timeout)
|
|
76
|
+
self._session = aiohttp.ClientSession(
|
|
77
|
+
headers=headers,
|
|
78
|
+
timeout=timeout_config
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def close(self):
|
|
82
|
+
"""Close the HTTP session."""
|
|
83
|
+
if self._session and not self._session.closed:
|
|
84
|
+
await self._session.close()
|
|
85
|
+
self._session = None
|
|
86
|
+
|
|
87
|
+
async def request(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
path: str,
|
|
91
|
+
data: Optional[Dict[str, Any]] = None
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""
|
|
94
|
+
Make an HTTP request.
|
|
95
|
+
|
|
96
|
+
Honors `Retry-After` on 429 responses (issue #263): the client
|
|
97
|
+
sleeps for the header's value (capped) and retries up to
|
|
98
|
+
``max_retries`` times. After exhaustion a ``RateLimitError`` is
|
|
99
|
+
raised so callers can surface the back-pressure to the user.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
method: HTTP method
|
|
103
|
+
path: API endpoint path
|
|
104
|
+
data: Request data
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Response data
|
|
108
|
+
"""
|
|
109
|
+
await self._ensure_session()
|
|
110
|
+
|
|
111
|
+
url = f"{self.base_url}{path}"
|
|
112
|
+
attempts_remaining = self.max_retries
|
|
113
|
+
last_429_text: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
async with self._session.request(
|
|
118
|
+
method,
|
|
119
|
+
url,
|
|
120
|
+
json=data if data else None,
|
|
121
|
+
) as response:
|
|
122
|
+
if response.status == 429:
|
|
123
|
+
last_429_text = await response.text()
|
|
124
|
+
if attempts_remaining <= 0:
|
|
125
|
+
raise RateLimitError(
|
|
126
|
+
f"HTTP 429 after {self.max_retries} retries: "
|
|
127
|
+
f"{last_429_text}"
|
|
128
|
+
)
|
|
129
|
+
delay = _parse_retry_after(
|
|
130
|
+
response.headers.get("Retry-After")
|
|
131
|
+
)
|
|
132
|
+
logger.info(
|
|
133
|
+
"Vectorizer 429 — sleeping %.1fs before retry "
|
|
134
|
+
"(remaining attempts=%d)",
|
|
135
|
+
delay,
|
|
136
|
+
attempts_remaining,
|
|
137
|
+
)
|
|
138
|
+
attempts_remaining -= 1
|
|
139
|
+
await asyncio.sleep(delay)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if response.status >= 400:
|
|
143
|
+
error_text = await response.text()
|
|
144
|
+
raise self._handle_error(response.status, error_text)
|
|
145
|
+
|
|
146
|
+
content_type = response.headers.get('Content-Type', '')
|
|
147
|
+
if 'application/json' in content_type:
|
|
148
|
+
return await response.json()
|
|
149
|
+
return await response.text()
|
|
150
|
+
|
|
151
|
+
except (ServerError, AuthenticationError, RateLimitError):
|
|
152
|
+
raise
|
|
153
|
+
except aiohttp.ClientError as e:
|
|
154
|
+
raise NetworkError(f"HTTP request failed: {e}")
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
raise NetworkError("Request timeout")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
raise NetworkError(f"Unknown error: {e}")
|
|
159
|
+
|
|
160
|
+
async def get(self, path: str) -> Any:
|
|
161
|
+
"""Make a GET request."""
|
|
162
|
+
return await self.request("GET", path)
|
|
163
|
+
|
|
164
|
+
async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
165
|
+
"""Make a POST request."""
|
|
166
|
+
return await self.request("POST", path, data)
|
|
167
|
+
|
|
168
|
+
async def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
169
|
+
"""Make a PUT request."""
|
|
170
|
+
return await self.request("PUT", path, data)
|
|
171
|
+
|
|
172
|
+
async def delete(self, path: str) -> Any:
|
|
173
|
+
"""Make a DELETE request."""
|
|
174
|
+
return await self.request("DELETE", path)
|
|
175
|
+
|
|
176
|
+
def _handle_error(self, status: int, error_text: str) -> Exception:
|
|
177
|
+
"""Handle HTTP errors and convert to appropriate exceptions."""
|
|
178
|
+
message = f"HTTP {status}: {error_text}"
|
|
179
|
+
|
|
180
|
+
if status == 401:
|
|
181
|
+
return AuthenticationError(message)
|
|
182
|
+
elif status == 403:
|
|
183
|
+
return AuthenticationError("Access forbidden")
|
|
184
|
+
elif status == 404:
|
|
185
|
+
return ServerError("Resource not found")
|
|
186
|
+
elif status == 429:
|
|
187
|
+
# 429 is handled in `request()` via Retry-After; reaching
|
|
188
|
+
# here means the caller bypassed retry handling, so
|
|
189
|
+
# surface a typed RateLimitError instead of a generic 5xx.
|
|
190
|
+
return RateLimitError(message)
|
|
191
|
+
elif status in (500, 502, 503, 504):
|
|
192
|
+
return ServerError(message)
|
|
193
|
+
else:
|
|
194
|
+
return ServerError(message)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_retry_after(value: Optional[str]) -> float:
|
|
198
|
+
"""Parse a ``Retry-After`` header value (seconds form only).
|
|
199
|
+
|
|
200
|
+
Returns a sane default + caps an unreasonably large server hint
|
|
201
|
+
so a misconfigured server can't pin a client into a long sleep.
|
|
202
|
+
"""
|
|
203
|
+
if not value:
|
|
204
|
+
return _RETRY_AFTER_DEFAULT_SECONDS
|
|
205
|
+
try:
|
|
206
|
+
seconds = float(value.strip())
|
|
207
|
+
except ValueError:
|
|
208
|
+
return _RETRY_AFTER_DEFAULT_SECONDS
|
|
209
|
+
if seconds <= 0:
|
|
210
|
+
return _RETRY_AFTER_DEFAULT_SECONDS
|
|
211
|
+
return min(seconds, _RETRY_AFTER_MAX_SECONDS)
|
|
212
|
+
|
|
@@ -112,6 +112,22 @@ class RestTransport(Transport):
|
|
|
112
112
|
await self._http.close()
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def _looks_like_jwt(token: str) -> bool:
|
|
116
|
+
"""Cheap JWT shape sniff: three non-empty base64url-encoded segments
|
|
117
|
+
joined by ``.``. Raw Vectorizer API keys (from ``POST /auth/keys``)
|
|
118
|
+
are a single alphanumeric string and fail this check, so they get
|
|
119
|
+
routed to ``X-API-Key`` rather than ``Authorization: Bearer``.
|
|
120
|
+
|
|
121
|
+
The server's auth middleware treats every ``Bearer`` string as a
|
|
122
|
+
JWT and never falls back to the API-key validator, so sending a
|
|
123
|
+
raw key under ``Bearer`` silently 401s. This sniff keeps the public
|
|
124
|
+
``api_key`` config field unchanged while routing each credential
|
|
125
|
+
down the header the server actually accepts.
|
|
126
|
+
"""
|
|
127
|
+
parts = token.split(".")
|
|
128
|
+
return len(parts) == 3 and all(p for p in parts)
|
|
129
|
+
|
|
130
|
+
|
|
115
131
|
@dataclass
|
|
116
132
|
class AuthState:
|
|
117
133
|
"""Bearer-token + API-key state shared across surface clients."""
|
|
@@ -121,7 +137,9 @@ class AuthState:
|
|
|
121
137
|
def headers(self) -> dict:
|
|
122
138
|
if not self.api_key:
|
|
123
139
|
return {}
|
|
124
|
-
|
|
140
|
+
if _looks_like_jwt(self.api_key):
|
|
141
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
142
|
+
return {"X-API-Key": self.api_key}
|
|
125
143
|
|
|
126
144
|
|
|
127
145
|
@dataclass
|
|
@@ -22,7 +22,8 @@ class AuthClient(_ApiBase):
|
|
|
22
22
|
|
|
23
23
|
from vectorizer import RestTransport, AuthClient
|
|
24
24
|
auth = AuthClient(RestTransport("http://localhost:15002"), api_key="sk-...")
|
|
25
|
-
print(auth.headers()) #
|
|
25
|
+
print(auth.headers()) # raw API key → {"X-API-Key": "sk-..."}
|
|
26
|
+
# # JWT → {"Authorization": "Bearer <jwt>"}
|
|
26
27
|
|
|
27
28
|
In the :class:`vectorizer.VectorizerClient` facade this is the
|
|
28
29
|
surface that owns the API key and surfaces it via :attr:`api_key`
|
|
@@ -47,3 +48,28 @@ class AuthClient(_ApiBase):
|
|
|
47
48
|
def set_api_key(self, api_key: Optional[str]) -> None:
|
|
48
49
|
"""Replace the current API key — affects future request headers."""
|
|
49
50
|
self._auth.api_key = api_key
|
|
51
|
+
|
|
52
|
+
async def login(self, username: str, password: str) -> str:
|
|
53
|
+
"""Exchange ``(username, password)`` for a JWT via
|
|
54
|
+
``POST /auth/login`` and return the raw ``access_token``.
|
|
55
|
+
|
|
56
|
+
The token is **not** stored on ``self`` — to use it for
|
|
57
|
+
subsequent requests, call :meth:`set_api_key` with the token
|
|
58
|
+
(``AuthState.headers`` sniffs the JWT shape and routes it onto
|
|
59
|
+
``Authorization: Bearer …`` automatically).
|
|
60
|
+
|
|
61
|
+
When the server runs with ``auth.enabled: false`` this
|
|
62
|
+
endpoint returns 404 — dev servers without auth don't need
|
|
63
|
+
to call :meth:`login` at all.
|
|
64
|
+
"""
|
|
65
|
+
data = await self._transport.post(
|
|
66
|
+
"/auth/login",
|
|
67
|
+
{"username": username, "password": password},
|
|
68
|
+
)
|
|
69
|
+
token = data.get("access_token") if isinstance(data, dict) else None
|
|
70
|
+
if not isinstance(token, str) or not token:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"login response missing `access_token`; server payload: "
|
|
73
|
+
f"{data!r}"
|
|
74
|
+
)
|
|
75
|
+
return token
|
|
@@ -274,7 +274,16 @@ class VectorizerClient:
|
|
|
274
274
|
if self._session is None or self._session.closed:
|
|
275
275
|
headers = {}
|
|
276
276
|
if self.api_key:
|
|
277
|
-
|
|
277
|
+
# Route JWTs to `Authorization: Bearer` and raw API
|
|
278
|
+
# keys to `X-API-Key`. Server middleware treats
|
|
279
|
+
# every Bearer string as a JWT and won't fall back
|
|
280
|
+
# to the API-key validator — sending a raw key as
|
|
281
|
+
# Bearer silently 401s on every gated route.
|
|
282
|
+
from ._base import _looks_like_jwt
|
|
283
|
+
if _looks_like_jwt(self.api_key):
|
|
284
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
285
|
+
else:
|
|
286
|
+
headers["X-API-Key"] = self.api_key
|
|
278
287
|
|
|
279
288
|
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
280
289
|
self._session = aiohttp.ClientSession(
|
|
@@ -112,6 +112,15 @@ class VectorsClient(_ApiBase):
|
|
|
112
112
|
"""
|
|
113
113
|
Get a specific vector by ID.
|
|
114
114
|
|
|
115
|
+
.. warning::
|
|
116
|
+
**Server caveat (observed on ``hivehub/vectorizer:3.0.x``):**
|
|
117
|
+
this endpoint currently returns HTTP 200 with a synthetic
|
|
118
|
+
uniform-vector payload (``[0.1, 0.1, ...]``) even for ids
|
|
119
|
+
that don't exist. Callers that need real miss detection
|
|
120
|
+
should probe via :meth:`list_vectors` or search and not
|
|
121
|
+
trust a successful response as proof of existence until
|
|
122
|
+
the server fix ships.
|
|
123
|
+
|
|
115
124
|
Args:
|
|
116
125
|
collection: Collection name
|
|
117
126
|
vector_id: Vector ID
|
|
@@ -125,6 +134,44 @@ class VectorsClient(_ApiBase):
|
|
|
125
134
|
ServerError: If service returns error
|
|
126
135
|
"""
|
|
127
136
|
return await self._transport.get(f"/collections/{collection}/vectors/{vector_id}")
|
|
137
|
+
|
|
138
|
+
async def insert_text_batch(
|
|
139
|
+
self,
|
|
140
|
+
collection: str,
|
|
141
|
+
texts: List[Dict[str, Any]],
|
|
142
|
+
) -> Dict[str, Any]:
|
|
143
|
+
"""Insert a batch of **texts** into a collection. The server
|
|
144
|
+
embeds each entry with the collection's configured provider
|
|
145
|
+
(BM25 default, FastEmbed ONNX when selected in ``config.yml``).
|
|
146
|
+
|
|
147
|
+
Wire contract: ``POST /insert_texts`` with
|
|
148
|
+
``{"collection": ..., "texts": [...]}`` payload. The collection
|
|
149
|
+
is a top-level JSON field, **not** a path segment.
|
|
150
|
+
|
|
151
|
+
Per-entry ``id``: the server **reassigns** every inserted
|
|
152
|
+
vector a server-generated UUID. The original client id
|
|
153
|
+
round-trips on the response as ``client_id``. Callers that
|
|
154
|
+
need idempotency by client id should key off the returned
|
|
155
|
+
``results[].client_id``, not the server-assigned UUID.
|
|
156
|
+
|
|
157
|
+
Distinct from :meth:`insert_texts` on this class, which takes
|
|
158
|
+
:class:`Vector` objects with pre-computed embeddings and hits
|
|
159
|
+
the raw-vector write path.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
collection: Collection name.
|
|
163
|
+
texts: Entries of shape
|
|
164
|
+
``{"id": "...", "text": "...", "metadata": {...}}``.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Raw ``/insert_texts`` response dict with fields
|
|
168
|
+
``collection``, ``count``, ``inserted``, ``failed``,
|
|
169
|
+
``results``.
|
|
170
|
+
"""
|
|
171
|
+
if not texts:
|
|
172
|
+
raise ValidationError("texts list cannot be empty")
|
|
173
|
+
payload = {"collection": collection, "texts": texts}
|
|
174
|
+
return await self._transport.post("/insert_texts", data=payload)
|
|
128
175
|
async def delete_vectors(self, collection: str, vector_ids: List[str]) -> bool:
|
|
129
176
|
"""
|
|
130
177
|
Delete vectors from a collection.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vectorizer_sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Python SDK for Vectorizer - Semantic search, vector operations, and the VectorizerRPC binary transport (default in v3.x)
|
|
5
5
|
Author-email: HiveLLM Team <team@hivellm.org>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -58,9 +58,38 @@ Dynamic: license-file
|
|
|
58
58
|
A comprehensive Python SDK for the Vectorizer semantic search service.
|
|
59
59
|
|
|
60
60
|
**Package**: `vectorizer_sdk` (PEP 625 compliant)
|
|
61
|
-
**Version**: 3.
|
|
61
|
+
**Version**: 3.2.0
|
|
62
62
|
**PyPI**: https://pypi.org/project/vectorizer-sdk/
|
|
63
63
|
|
|
64
|
+
## v3.2 — backpressure-aware client (HTTP 429 + `Retry-After`)
|
|
65
|
+
|
|
66
|
+
The REST `VectorizerClient` honors server-side bulk-upsert
|
|
67
|
+
backpressure shipped in Vectorizer 3.2.0
|
|
68
|
+
([#263](https://github.com/hivellm/vectorizer/issues/263)). On HTTP
|
|
69
|
+
`429 Too Many Requests` the client parses `Retry-After` (seconds
|
|
70
|
+
form, 1 s default, 30 s cap), sleeps, and retries up to 3 times
|
|
71
|
+
before raising a typed `RateLimitError`. Pre-3.2.0 clients bounced
|
|
72
|
+
429s into a generic 5xx and lost the retry budget. Identical
|
|
73
|
+
semantics ship in every first-party SDK (Rust, Python, TypeScript,
|
|
74
|
+
Go, C#) — see `tests/test_retry_after_parse.py`.
|
|
75
|
+
|
|
76
|
+
## v3.1 — `/insert_vectors` + stable client-id upserts
|
|
77
|
+
|
|
78
|
+
- `insert_vectors(collection, vectors, public_key=None)` — bulk-
|
|
79
|
+
insert pre-computed embeddings with caller-supplied vector ids.
|
|
80
|
+
Skips the embedding pipeline entirely.
|
|
81
|
+
- `insert` / `insert_texts`: the request `id` is now used verbatim
|
|
82
|
+
as the stored `Vector.id` (non-chunked) or as `<id>#<chunk_index>`
|
|
83
|
+
(chunked). Re-running the same payload upserts in place instead
|
|
84
|
+
of duplicating.
|
|
85
|
+
- Chunked vectors expose a flat payload layout (`{content,
|
|
86
|
+
file_path, chunk_index, parent_id, ...user_metadata}`). Legacy
|
|
87
|
+
nested payloads from ≤ 3.0.x stay readable during the deprecation
|
|
88
|
+
window.
|
|
89
|
+
|
|
90
|
+
Client-id contract: non-empty, length ≤ 256, no leading/trailing
|
|
91
|
+
whitespace, must not contain `#`.
|
|
92
|
+
|
|
64
93
|
## v3.0 — VectorizerRPC is the default transport
|
|
65
94
|
|
|
66
95
|
Starting with v3.0, the recommended transport is **VectorizerRPC**: a
|
|
@@ -79,6 +108,8 @@ import vectorizer_sdk
|
|
|
79
108
|
|
|
80
109
|
async def main():
|
|
81
110
|
client = await vectorizer_sdk.connect_async("vectorizer://127.0.0.1:15503")
|
|
111
|
+
# `hello` and `search_basic` are RPC-only (not available on the legacy
|
|
112
|
+
# REST `VectorizerClient`).
|
|
82
113
|
await client.hello(vectorizer_sdk.HelloPayload(client_name="my-app"))
|
|
83
114
|
print(await client.list_collections())
|
|
84
115
|
hits = await client.search_basic("docs", "vector database", limit=5)
|
|
@@ -140,7 +171,7 @@ for a runnable end-to-end example.
|
|
|
140
171
|
pip install vectorizer-sdk
|
|
141
172
|
|
|
142
173
|
# Or specific version
|
|
143
|
-
pip install vectorizer-sdk==3.
|
|
174
|
+
pip install vectorizer-sdk==3.2.0
|
|
144
175
|
```
|
|
145
176
|
|
|
146
177
|
## Package Layout (v3.x)
|
|
@@ -477,6 +508,11 @@ related = await client.get_related_files(
|
|
|
477
508
|
|
|
478
509
|
### Summarization Operations
|
|
479
510
|
|
|
511
|
+
> WARNING: The `/summarize/*` REST endpoints are documented but not yet wired
|
|
512
|
+
> server-side (see `DOC_GAP_ANALYSIS`). The SDK methods below
|
|
513
|
+
> (`summarize_text`, `summarize_context`) will fail until server wiring is
|
|
514
|
+
> complete.
|
|
515
|
+
|
|
480
516
|
#### Summarize Text
|
|
481
517
|
Summarize text using various methods:
|
|
482
518
|
|
|
@@ -509,6 +545,11 @@ summary = await client.summarize_context(
|
|
|
509
545
|
|
|
510
546
|
### Workspace Management
|
|
511
547
|
|
|
548
|
+
> WARNING: `add_workspace`, `list_workspaces`, and `remove_workspace` are
|
|
549
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
550
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
551
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
552
|
+
|
|
512
553
|
#### Add Workspace
|
|
513
554
|
Add a new workspace:
|
|
514
555
|
|
|
@@ -537,6 +578,11 @@ await client.remove_workspace(
|
|
|
537
578
|
|
|
538
579
|
### Backup Operations
|
|
539
580
|
|
|
581
|
+
> WARNING: `create_backup`, `list_backups`, and `restore_backup` are
|
|
582
|
+
> exposed via the REST transport through dynamic `__getattr__` delegation,
|
|
583
|
+
> but they are not first-class SDK methods yet. They work at runtime but
|
|
584
|
+
> won't autocomplete in IDEs. A future release will add explicit methods.
|
|
585
|
+
|
|
540
586
|
#### Create Backup
|
|
541
587
|
Create a backup of collections:
|
|
542
588
|
|
|
@@ -664,8 +710,11 @@ await client.create_collection("documents", dimension=768)
|
|
|
664
710
|
await client.insert_texts("documents", [
|
|
665
711
|
{"id": "doc1", "text": "Sample document", "metadata": {"source": "api"}}
|
|
666
712
|
])
|
|
667
|
-
|
|
668
|
-
await client.
|
|
713
|
+
# Update-via-reinsert: re-call `insert_texts` with the same id to replace the record.
|
|
714
|
+
await client.insert_texts("documents", [
|
|
715
|
+
{"id": "doc1", "text": "Sample document (updated)", "metadata": {"updated": True}}
|
|
716
|
+
])
|
|
717
|
+
await client.delete_vectors("documents", ["doc1"])
|
|
669
718
|
|
|
670
719
|
# Reads automatically go to replicas (load balanced)
|
|
671
720
|
results = await client.search_vectors("documents", query="sample", limit=10)
|
|
@@ -702,7 +751,7 @@ The SDK automatically classifies operations:
|
|
|
702
751
|
|
|
703
752
|
| Operation Type | Routed To | Methods |
|
|
704
753
|
|---------------|-----------|---------|
|
|
705
|
-
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `
|
|
754
|
+
| **Writes** | Always Master | `insert_texts`, `insert_vectors`, `delete_vectors`, `create_collection`, `delete_collection` |
|
|
706
755
|
| **Reads** | Based on `read_preference` | `search_vectors`, `get_vector`, `list_collections`, `intelligent_search`, `semantic_search`, `hybrid_search` |
|
|
707
756
|
|
|
708
757
|
#### Standalone Mode (Single Node)
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
HTTP client utility for making API requests using aiohttp.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from typing import Optional, Dict, Any
|
|
6
|
-
import aiohttp
|
|
7
|
-
import asyncio
|
|
8
|
-
import logging
|
|
9
|
-
|
|
10
|
-
try:
|
|
11
|
-
from ..exceptions import (
|
|
12
|
-
NetworkError,
|
|
13
|
-
ServerError,
|
|
14
|
-
AuthenticationError,
|
|
15
|
-
)
|
|
16
|
-
except ImportError:
|
|
17
|
-
from exceptions import (
|
|
18
|
-
NetworkError,
|
|
19
|
-
ServerError,
|
|
20
|
-
AuthenticationError,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class HTTPClient:
|
|
27
|
-
"""HTTP transport client."""
|
|
28
|
-
|
|
29
|
-
def __init__(
|
|
30
|
-
self,
|
|
31
|
-
base_url: str = "http://localhost:15002",
|
|
32
|
-
api_key: Optional[str] = None,
|
|
33
|
-
timeout: int = 30,
|
|
34
|
-
max_retries: int = 3
|
|
35
|
-
):
|
|
36
|
-
"""
|
|
37
|
-
Initialize HTTP client.
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
base_url: Base URL for HTTP API
|
|
41
|
-
api_key: API key for authentication
|
|
42
|
-
timeout: Request timeout in seconds
|
|
43
|
-
max_retries: Maximum number of retry attempts
|
|
44
|
-
"""
|
|
45
|
-
self.base_url = base_url.rstrip('/')
|
|
46
|
-
self.api_key = api_key
|
|
47
|
-
self.timeout = timeout
|
|
48
|
-
self.max_retries = max_retries
|
|
49
|
-
self._session: Optional[aiohttp.ClientSession] = None
|
|
50
|
-
|
|
51
|
-
async def _ensure_session(self):
|
|
52
|
-
"""Ensure aiohttp session is created."""
|
|
53
|
-
if self._session is None or self._session.closed:
|
|
54
|
-
headers = {"Content-Type": "application/json"}
|
|
55
|
-
if self.api_key:
|
|
56
|
-
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
57
|
-
|
|
58
|
-
timeout_config = aiohttp.ClientTimeout(total=self.timeout)
|
|
59
|
-
self._session = aiohttp.ClientSession(
|
|
60
|
-
headers=headers,
|
|
61
|
-
timeout=timeout_config
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
async def close(self):
|
|
65
|
-
"""Close the HTTP session."""
|
|
66
|
-
if self._session and not self._session.closed:
|
|
67
|
-
await self._session.close()
|
|
68
|
-
self._session = None
|
|
69
|
-
|
|
70
|
-
async def request(
|
|
71
|
-
self,
|
|
72
|
-
method: str,
|
|
73
|
-
path: str,
|
|
74
|
-
data: Optional[Dict[str, Any]] = None
|
|
75
|
-
) -> Any:
|
|
76
|
-
"""
|
|
77
|
-
Make an HTTP request.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
method: HTTP method
|
|
81
|
-
path: API endpoint path
|
|
82
|
-
data: Request data
|
|
83
|
-
|
|
84
|
-
Returns:
|
|
85
|
-
Response data
|
|
86
|
-
"""
|
|
87
|
-
await self._ensure_session()
|
|
88
|
-
|
|
89
|
-
url = f"{self.base_url}{path}"
|
|
90
|
-
|
|
91
|
-
try:
|
|
92
|
-
async with self._session.request(
|
|
93
|
-
method,
|
|
94
|
-
url,
|
|
95
|
-
json=data if data else None
|
|
96
|
-
) as response:
|
|
97
|
-
if response.status >= 400:
|
|
98
|
-
error_text = await response.text()
|
|
99
|
-
raise self._handle_error(response.status, error_text)
|
|
100
|
-
|
|
101
|
-
content_type = response.headers.get('Content-Type', '')
|
|
102
|
-
if 'application/json' in content_type:
|
|
103
|
-
return await response.json()
|
|
104
|
-
return await response.text()
|
|
105
|
-
|
|
106
|
-
except (ServerError, AuthenticationError):
|
|
107
|
-
raise
|
|
108
|
-
except aiohttp.ClientError as e:
|
|
109
|
-
raise NetworkError(f"HTTP request failed: {e}")
|
|
110
|
-
except asyncio.TimeoutError:
|
|
111
|
-
raise NetworkError("Request timeout")
|
|
112
|
-
except Exception as e:
|
|
113
|
-
raise NetworkError(f"Unknown error: {e}")
|
|
114
|
-
|
|
115
|
-
async def get(self, path: str) -> Any:
|
|
116
|
-
"""Make a GET request."""
|
|
117
|
-
return await self.request("GET", path)
|
|
118
|
-
|
|
119
|
-
async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
120
|
-
"""Make a POST request."""
|
|
121
|
-
return await self.request("POST", path, data)
|
|
122
|
-
|
|
123
|
-
async def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
124
|
-
"""Make a PUT request."""
|
|
125
|
-
return await self.request("PUT", path, data)
|
|
126
|
-
|
|
127
|
-
async def delete(self, path: str) -> Any:
|
|
128
|
-
"""Make a DELETE request."""
|
|
129
|
-
return await self.request("DELETE", path)
|
|
130
|
-
|
|
131
|
-
def _handle_error(self, status: int, error_text: str) -> Exception:
|
|
132
|
-
"""Handle HTTP errors and convert to appropriate exceptions."""
|
|
133
|
-
message = f"HTTP {status}: {error_text}"
|
|
134
|
-
|
|
135
|
-
if status == 401:
|
|
136
|
-
return AuthenticationError(message)
|
|
137
|
-
elif status == 403:
|
|
138
|
-
return AuthenticationError("Access forbidden")
|
|
139
|
-
elif status == 404:
|
|
140
|
-
return ServerError("Resource not found")
|
|
141
|
-
elif status in (429, 500, 502, 503, 504):
|
|
142
|
-
return ServerError(message)
|
|
143
|
-
else:
|
|
144
|
-
return ServerError(message)
|
|
145
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|