Metadata-Version: 2.4
Name: aetherfy-vectors
Version: 1.1.0
Summary: Global vector database client with automatic replication and low latency worldwide
Home-page: https://github.com/l-td/aetherfy-vectors-python-sdk
Download-URL: https://pypi.org/project/aetherfy-vectors/
Author: Aetherfy
Author-email: developers@aetherfy.com
Maintainer: Aetherfy Team
Maintainer-email: developers@aetherfy.com
License: MIT
Project-URL: Homepage, https://aetherfy.com
Project-URL: Documentation, https://docs.aetherfy.com/vectors
Project-URL: Source, https://github.com/l-td/aetherfy-vectors-python-sdk
Project-URL: Bug Reports, https://github.com/l-td/aetherfy-vectors-python-sdk/issues
Project-URL: API Reference, https://vectors.aetherfy.com/docs
Keywords: vector database,similarity search,machine learning,artificial intelligence,AI,ML,embeddings,qdrant,global database,edge computing,vector search,semantic search,neural search,recommendation systems,retrieval augmented generation,RAG
Platform: any
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Database
Classifier: Topic :: Database :: Database Engines/Servers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests<3.0.0,>=2.28.0
Requires-Dist: pydantic<3.0.0,>=1.10.0
Requires-Dist: typing-extensions>=4.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: pytest-mock>=3.8.0; extra == "dev"
Requires-Dist: black>=22.0.0; extra == "dev"
Requires-Dist: flake8>=5.0.0; extra == "dev"
Requires-Dist: mypy>=0.991; extra == "dev"
Requires-Dist: pre-commit>=2.20.0; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=5.0.0; extra == "docs"
Requires-Dist: sphinx-rtd-theme>=1.0.0; extra == "docs"
Requires-Dist: sphinxcontrib-napoleon>=0.7; extra == "docs"
Provides-Extra: test
Requires-Dist: pytest>=7.0.0; extra == "test"
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
Requires-Dist: pytest-mock>=3.8.0; extra == "test"
Requires-Dist: responses>=0.21.0; extra == "test"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: download-url
Dynamic: home-page
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: maintainer
Dynamic: maintainer-email
Dynamic: platform
Dynamic: project-url
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# Aetherfy Vectors Python SDK

[![PyPI version](https://badge.fury.io/py/aetherfy-vectors.svg)](https://badge.fury.io/py/aetherfy-vectors)
[![Python Support](https://img.shields.io/pypi/pyversions/aetherfy-vectors.svg)](https://pypi.org/project/aetherfy-vectors/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Tests](https://github.com/l-td/aetherfy-vectors-python-sdk/workflows/Tests/badge.svg)](https://github.com/l-td/aetherfy-vectors-python-sdk/actions)

**Memory for AI agents** — conversations, knowledge bases, scrape logs, trading
histories. Named scopes you create and search, replicated across regions, with
no infrastructure to run.

## 🚀 Key Features

- **🧠 Memory Primitives**: `Namespace` for any named scope, `Thread` for conversations — not a raw collection API
- **🌍 Global Performance**: Low latency from anywhere in the world
- **⚡ Intelligent Caching**: High cache hit rates for optimal performance
- **🛡️ Zero DevOps**: No infrastructure management or regional deployment needed
- **🤝 Workspace Scoping**: Multi-agent tenancy, auto-detected inside deployed agents
- **📊 Built-in Analytics**: Real-time performance metrics and usage insights
- **🔧 Auto-Failover**: Intelligent routing and retry mechanisms
- **🔐 Enterprise Security**: API key authentication and audit logging
- **🪜 Escape Hatch**: `AetherfyVectorsClient` underneath, API-compatible with `qdrant-client`

## 📦 Installation

```bash
pip install aetherfy-vectors
```

## 🏃‍♂️ Quick Start

Every memory lives in a scope you create by name. Two kinds: a **namespace** for
anything with a name, a **thread** for a conversation. Bring your own embedding
vectors — Aetherfy stores and searches them, it does not generate them.

```python
from aetherfy_memory import MemoryClient

memory = MemoryClient()  # reads AETHERFY_API_KEY; workspace auto-detected

# --- A namespace: any named scope ---------------------------------------
memory.create_namespace("customer-42")
customer = memory.namespace("customer-42")

customer.add(text="Lives in NYC, prefers email", vector=embed("Lives in NYC, prefers email"))
hits = customer.search(vector=embed("where is this customer based?"), limit=5)

# --- A thread: a conversation -------------------------------------------
memory.create_thread("conv-99")
thread = memory.thread("conv-99")

thread.add(role="user", content="hi", vector=embed("hi"))
thread.add(role="assistant", content="hello", vector=embed("hello"))
recent = thread.history(limit=20)   # in message order

# Deleting a scope is atomic — it drops the whole backing collection.
thread.clear()
```

Scopes must be created before you write to them, so a typo raises instead of
silently creating a second store. `vector_size` defaults to 384; pass your
model's dimension to `create_namespace` / `create_thread` if it differs
(1536 for OpenAI small, 3072 for large, 1024 for Cohere v3).

Deployed on Aetherfy, `MemoryClient()` takes no arguments at all: the control
plane injects `AETHERFY_API_KEY` and the workspace at deploy time.

Full memory API — iteration, bulk loading, metadata — under
[Memory SDK](#-memory-sdk--iter-bulk-load-set_metadata) below.

## 🧱 The Low-Level Client

`AetherfyVectorsClient` is the layer `MemoryClient` is built on, exported and
supported. Use it when you want raw collections and points rather than named
scopes, or an operation the memory layer doesn't expose.

The snippets below build on each other; each assumes the imports and client
from the previous ones.

### Migration from qdrant-client

Replace your existing qdrant-client code with just **2 lines changed**:

```python
# Before (qdrant-client)
from qdrant_client import QdrantClient
client = QdrantClient(host="localhost", port=6333)

# After (aetherfy-vectors) - Only 2 changes needed!
from aetherfy_vectors import AetherfyVectorsClient
client = AetherfyVectorsClient(api_key="afy_live_your_api_key_here")

# All your existing code works unchanged! 🎉
results = client.search(collection_name="my_collection", query_vector=[0.1, 0.2, 0.3])
```

### Basic Usage Example

```python
from aetherfy_vectors import AetherfyVectorsClient
from aetherfy_vectors.models import VectorConfig, DistanceMetric

# Initialize client
client = AetherfyVectorsClient(api_key="afy_live_your_api_key_here")

# Create a collection
client.create_collection(
    "documents",
    VectorConfig(size=128, distance=DistanceMetric.COSINE)
)

# Add vectors — a point id is an unsigned integer (<= 2**53 - 1) or a UUID string
points = [
    {
        "id": 1,
        "vector": [0.1, 0.2, ...],  # 128-dimensional vector
        "payload": {"title": "Document 1", "category": "research"}
    }
]
client.upsert("documents", points)

# Search for similar vectors
results = client.search(
    collection_name="documents",
    query_vector=[0.1, 0.2, ...],
    limit=10,
    with_payload=True
)

for result in results:
    print(f"ID: {result.id}, Score: {result.score}")
    print(f"Title: {result.payload['title']}")
```

## 🌟 Unique Features

### Usage Against Your Plan Limits

```python
usage = client.get_usage_stats()
print(f"Storage used: {usage.storage_bytes_used:,} bytes")
print(f"Collections: {usage.collections_count}/{usage.collections_limit}")
print(f"Tier: {usage.tier}")
print(f"Replicating to: {', '.join(usage.active_regions)}")
```

### Intelligent Global Routing

Your requests are automatically routed to the optimal region:

```python
# No configuration needed - routing is automatic!
# Requests from the US  → the US region
# Requests from the EU  → the EU region
# Requests from Asia    → the Asia-Pacific region
```

## 🔧 Advanced Usage

### Batch Operations

Efficient batch processing with automatic optimization:

```python
# Bulk insert thousands of points (ids are unsigned integers or UUID strings)
large_batch = [
    {"id": i, "vector": [...], "payload": {...}}
    for i in range(10000)
]

# Automatically optimized batch size and routing
client.upsert("large_collection", large_batch)
```

### Complex Filtering

Full compatibility with qdrant-client filters. Three clauses, and only
three: `must`, `must_not`, `should`.

```python
results = client.search(
    collection_name="products",
    query_vector=[...],
    query_filter={
        "must": [
            {"key": "category", "match": {"value": "electronics"}},
            {"key": "price", "range": {"gte": 100, "lte": 1000}}
        ],
        "must_not": [
            {"key": "status", "match": {"value": "discontinued"}}
        ],
    },
    limit=20
)
```

The same clauses are available as a typed object, if you prefer it to a dict:

```python
from aetherfy_vectors.models import Filter

results = client.search(
    collection_name="products",
    query_vector=[...],
    query_filter=Filter(
        must=[{"key": "category", "match": {"value": "electronics"}}],
        must_not=[{"key": "status", "match": {"value": "discontinued"}}],
    ),
    limit=20,
)
```

A clause name outside those three raises `ValidationError` before the
request leaves the process — including `mustNot`, which is the JavaScript
SDK's spelling of `must_not`. Unknown clauses are never forwarded and never
dropped: writing the wrong one is an error you see, not a filter that
quietly matches everything. The same rule applies everywhere a filter is
accepted — `search`, `scroll`, `scroll_iter`, `count`, `delete`.

### Context Manager Support

```python
with AetherfyVectorsClient(api_key="your_key") as client:
    results = client.search("collection", [0.1, 0.2, 0.3])
    # Automatic cleanup
```

## 🔁 Iterating Large Collections

For bulk reads, use `scroll_iter()` rather than `scroll(limit=…)`. The
iterator pages transparently and stays within the server's per-request
caps (1000 points/call, 10 MB/response):

```python
for point in client.scroll_iter("my_collection", batch_size=256):
    process(point)

# With a filter and selective payload/vector return
for point in client.scroll_iter(
    "my_collection",
    batch_size=512,
    scroll_filter={"must": [{"key": "status", "match": {"value": "active"}}]},
    with_payload=True,
    with_vectors=False,
):
    process(point)
```

`batch_size` is the page size for one HTTP round trip (max 1000 server-side).
The iterator handles cursor management, page exhaustion, and pagination
errors — no offset bookkeeping in user code.

## ✏️ Editing Payload on Existing Points

Three operations on the payload of points that already exist — no need to
re-upsert vectors:

```python
# MERGE: add or update keys, leave others alone
client.set_payload(
    "my_collection",
    {"reviewed": True, "reviewer": "alice"},
    [point_id],
)

# OVERWRITE: replace the entire payload object
client.overwrite_payload(
    "my_collection",
    {"category": "X"},   # all other keys are dropped
    [point_id],
)

# DELETE: remove specific keys, leave others alone
client.delete_payload(
    "my_collection",
    ["draft_field", "stale_score"],
    [point_id],
)
```

Each call accepts up to **512 points** in one round trip; for larger
mutations, batch on the caller side. The semantics map exactly to
qdrant's `set_payload` / `overwrite_payload` / `delete_payload` so
existing patterns transfer.

## 🧠 Memory SDK — Iter, Bulk-load, set_metadata

The Memory layer (`aetherfy_memory`) layers `Namespace` and `Thread`
abstractions on top of `AetherfyVectorsClient`. Three additions worth
knowing once you go past `add()` / `search()`:

### Iterating a namespace or a thread

```python
from aetherfy_memory import MemoryClient
memory = MemoryClient(api_key="afy_live_…", workspace="my-bot")

ns = memory.namespace("customer-42")
for point in ns.iter(batch_size=256):
    process(point)

# Threads have iter_history() — yields messages in ts order across the
# whole conversation. Distinct from history(limit=N), which caps at 5000
# in memory for the most-recent slice.
thread = memory.thread("conv-99")
for msg in thread.iter_history(order="asc"):
    print(msg.role, msg.content)
```

Use `history(limit=N)` for "show me the last N messages" (bounded, fast).
Use `iter_history()` for "walk every message in this thread" (paged,
memory-bounded by the iterator).

### Bulk-loading memories

`add_many()` and `append_many()` batch into a single `client.upsert` so
N items become 1 round trip. IDs are returned in input order; missing
IDs are auto-generated as canonical UUIDs (the same format `iter()` and
`retrieve()` yield back, so equality comparisons just work).

```python
items = [
    {"text": "first",  "vector": embed("first"),  "metadata": {"src": "a"}},
    {"text": "second", "vector": embed("second"), "metadata": {"src": "b"}},
]
ids = ns.add_many(items)            # single round trip; preserves input order

# Threads use append_many — role/content/ts payloads, ts auto-set per
# message when omitted (each message gets its own ts, not one shared).
msgs = [
    {"role": "user",      "content": "hi",    "vector": embed("hi")},
    {"role": "assistant", "content": "hello", "vector": embed("hello")},
]
ids = thread.append_many(msgs)
```

Threads have no `add_many()` — a `Thread` is not a `Namespace` subclass
(they share a scope base but declare their own write API), so there is
no `add_many` to call. `add_many` writes `text`/`metadata` payloads,
which don't fit a thread's `role`/`content`/`ts` schema. Use
`append_many()` on threads.

### set_metadata — atomic replace, explicit-compose merge

`set_metadata()` replaces the entire metadata sub-key.
`set_metadata({"tag": "x"})` nukes every other key. Use
`merge_metadata()` if you want additive updates that preserve existing
keys. Reserved fields (`text` for Namespace; `role`/`content`/`ts` for
Thread) are untouched either way.

```python
ns.set_metadata(point_id, {"reviewed": True, "score": 0.92})
```

To merge into existing metadata via the explicit-compose pattern (race
visible at the call site, no atomicity guarantee):

```python
current = ns.retrieve([point_id])[0]["payload"].get("metadata", {})
current.update({"reviewed": True})
ns.set_metadata(point_id, current)
```

If two callers run this concurrently, one update wins and the other
sees its read be stale — by design, you see that race in your own code
rather than have the SDK hide it.

### merge_metadata — atomic per-point partial merge

`merge_metadata({"tag": "x"})` adds/updates the listed keys and leaves
every other key untouched. Concurrent patches to different keys all
land atomically; concurrent writes to the same key resolve via
last-writer-wins per the storage operation order. Raises
`PointNotFoundError` if the point doesn't exist. Reserved keys (`text`
on Namespace; `role`, `content`, `ts` on Thread) cannot appear in the
partial — raises a local `ValueError` before the request is sent.

```python
ns.merge_metadata(point_id, {"reviewed": True})
ns.merge_metadata(point_id, {"score": 0.92})
# final metadata: original keys + reviewed + score
```

### delete_metadata_keys — atomic key removal

`delete_metadata_keys(point_id, ["tag", "score"])` removes the listed
keys from metadata; keys not in the list are left untouched. Raises
`PointNotFoundError` if the point doesn't exist. Reserved keys cannot
appear in the keys list (same set as `merge_metadata`).

```python
ns.delete_metadata_keys(point_id, ["draft", "stale_score"])
```

## 📐 Limits

Two axes constrain a single call: per-request size (PRS) and requests
per minute (RPM). Both axes return a 4xx with a structured `error.code`
when they fire — no surprise 5xx, no silent truncation.

| Class | Endpoint | Cap |
|-------|----------|-----|
| READS | `scroll` · `search` · `retrieve` | ≤ 1000 points/call · ≤ 10 MB/response |
| WRITES | `upsert` | ≤ 10 K vectors/call · streaming |
|        | payload edits · batch delete | ≤ 512 points/call |

> **Upserts stream** — there is no body-size cap on the public upsert
> URL. The 10 K vectors/call is a defensive request-level ceiling, not
> a body limit; one call can upload millions of bytes via byte-target
> chunking on the receiving end. For bulk reads, use `scroll_iter()` —
> it pages transparently and stays within both quotas.

`requests_per_minute` is a sliding-window minutely cap derived from your
subscription tier. When it fires, the SDK raises
`RateLimitExceededError` with a structured `retry_after` (seconds);
PRS violations raise `ValidationError` (400) or surface as 413
`RESPONSE_TOO_LARGE` for oversized response bodies.

## 🤝 Multi-Agent Workspaces

Workspaces let multiple agents share vector collections without name collisions. All collections created through a workspace-scoped client are automatically namespaced — agents in the same workspace see each other's collections; agents in different workspaces are fully isolated.

### Creating a workspace-scoped client

```python
from aetherfy_vectors import AetherfyVectorsClient

client = AetherfyVectorsClient(
    api_key="afy_live_your_api_key_here",
    workspace="invoice-pipeline",  # All operations are scoped to this workspace
)
```

### How scoping works

Collection names are automatically prefixed — you always use the short name:

```python
# Create a collection (stored as "invoice-pipeline/documents" internally)
from aetherfy_vectors.models import VectorConfig, DistanceMetric

client.create_collection("documents", VectorConfig(size=768, distance=DistanceMetric.COSINE))

# Search — no need to know the full scoped name
results = client.search("documents", query_vector=embedding, limit=10)

# List — only returns collections in your workspace
collections = client.get_collections()
# → [Collection(name='documents', ...)]  (short names, not scoped names)
```

### Multi-agent example

```python
# Agent A: extractor
extractor = AetherfyVectorsClient(api_key=api_key, workspace="invoice-pipeline")
extractor.create_collection("raw-invoices", VectorConfig(size=768, distance=DistanceMetric.COSINE))
extractor.upsert("raw-invoices", extracted_points)

# Agent B: classifier — same workspace, sees Agent A's collection
classifier = AetherfyVectorsClient(api_key=api_key, workspace="invoice-pipeline")
results = classifier.search("raw-invoices", query_vector=embedding, limit=20)
```

### Workspace auto-detection from environment

Agents deployed by Aetherfy automatically have `AETHERFY_WORKSPACE` set. The client picks this up if no `workspace` is passed explicitly:

```python
import os

# In a deployed agent, AETHERFY_WORKSPACE is injected automatically
client = AetherfyVectorsClient(api_key=os.environ["AETHERFY_API_KEY"])
# → workspace is auto-detected from AETHERFY_WORKSPACE env var
```

### No workspace (backward-compatible)

```python
# No workspace — collections are stored as-is, not scoped
client = AetherfyVectorsClient(api_key="afy_live_your_key")
client.create_collection("my-global-collection", VectorConfig(size=768, distance=DistanceMetric.COSINE))
```

> **Tip:** Create workspaces explicitly in the Aetherfy control plane before use (`afy workspaces create invoice-pipeline`). Agents deployed to a workspace automatically receive the workspace name via `AETHERFY_WORKSPACE`.

## 🤖 Running as an Agent

Code deployed to Aetherfy as an agent gets a set of helpers in the same
distribution, under `aetherfy_agent`. Nothing to add to your requirements: the
standard runtime image preinstalls `aetherfy-vectors`, and a version you pin
yourself wins over it. A custom container installs it like any other package.

Each of these is a thin wrapper over a platform contract the docs already
publish; the helper exists so the contract stops being copied into every task.

```python
from aetherfy_agent import payload, machine, fan_out, spawn

# This run's input. A scheduled fire sends none, so {} is the normal case.
data = payload()

# The machine this run is on: whole numbers, not the strings the env carries.
shape = machine()

# An in-machine pool. Results come back in INPUT order and no failure is
# swallowed: the lowest-indexed exception is re-raised once every worker has
# finished. Width defaults to vcpus x 8 for threads, the default pool.
answers = fan_out(summarise, data.get("items", []))

# CPU-bound work wants processes. The default width follows the pool:
# threads get vcpus x 8 (they mostly wait), processes get vcpus.
digests = fan_out(hash_one, data.get("files", []), kind="processes")

# Run a different task agent, on its own machine, with its own lifecycle.
run = spawn("nightly-rollup", {"date": "2026-09-07"})
print(run.spawn_id, run.region, run.status)
```

`fan_out` prints one line to the run's logs before it starts, so how wide a run
went is visible after the fact:

```text
aetherfy: fanning out 32 wide on 4 vCPU / 8192 MB (120 tasks)
```

Spawning has three outcomes worth telling apart. The payload cap and the
concurrent-run cap get their own types; everything else carries the platform's
stable error code, which is the thing to branch on:

```python
from aetherfy_agent import spawn
from aetherfy_agent.exceptions import (
    PayloadTooLarge,
    SpawnError,
    TooManyRunsInFlight,
)

try:
    spawn("nightly-rollup", {"date": "2026-09-07"})
except PayloadTooLarge as exc:
    # The payload is for parameters and references, not data. Write the data to
    # a collection and pass its id.
    print(exc.payload_bytes, "exceeds", exc.max_bytes)
except TooManyRunsInFlight as exc:
    # The one refusal here worth retrying: runs are finishing all the time.
    # The cap is the ACCOUNT's and is set by your plan; `limit` names which
    # plan limit was hit, and `max_in_flight_runs` is None on an uncapped plan.
    print("waiting on", exc.in_flight_count, "of", exc.max_in_flight_runs)
except SpawnError as exc:
    print(exc.error_code)
```

### Returning a result, and reading one back

Output on Aetherfy mirrors input. A task writes its answer with `write_result`;
whoever started the run reads it back off the run itself, so a parent hears
from a child in another region with no side channel between them.

```python
from aetherfy_agent import write_result

# In the child task. The mirror of payload(): a file on the machine, nothing
# over the network. Returning nothing is the normal case, so most tasks never
# call this at all.
write_result({"rows": 128, "date": "2026-09-08"})
```

```python
from aetherfy_agent import spawn, wait, result

# In the parent. wait() holds one request open instead of polling; a run that
# has not finished in time comes back exactly as it stands, which is not an
# error — read `state` and call again.
run = spawn("nightly-rollup", {"date": "2026-09-08"})
finished = wait(run.spawn_id, timeout_seconds=45)

if finished.state == "completed":
    print(finished.result)

# result() is the same read without the waiting.
now = result(run.spawn_id)
print(now.state, now.has_result, now.result_error)
```

The result shares the payload's inline cap — one number bounding both
directions — and it is for answers and references, not data. Anything larger
belongs in a collection, with its id in the result:

```python
from aetherfy_agent import write_result
from aetherfy_agent.exceptions import ResultTooLarge

try:
    write_result(everything)
except ResultTooLarge as exc:
    print(exc.result_bytes, "exceeds", exc.max_bytes)
    write_result({"collection": "nightly-rollup", "rows": len(everything)})
```

Reading a run has the same shape as spawning it: the two refusals worth telling
apart get their own types, and everything else carries the platform's stable
error code.

```python
from aetherfy_agent import result
from aetherfy_agent.exceptions import RunAccessDenied, RunNotFound, RunReadError

try:
    result(run_id)
except RunNotFound:
    print("no run has that id")
except RunAccessDenied:
    print("that run belongs to another account")
except RunReadError as exc:
    print(exc.error_code)
```

Full contract, including the environment variables behind every call:
[docs.aetherfy.com/agents/task-contract](https://docs.aetherfy.com/agents/task-contract).

## 🧩 Payload Schemas

Collections can carry an optional payload schema that the SDK validates against **before** upsert — catching malformed payloads client-side without a round trip. Schemas are cached and automatically revalidated when they change server-side (via ETag).

```python
from aetherfy_vectors import Schema, FieldDefinition

schema = Schema(
    fields={
        "title":    FieldDefinition(type="string",  required=True),
        "price":    FieldDefinition(type="float",   required=True),
        "tags":     FieldDefinition(type="array",   required=False, element_type="string"),
        "in_stock": FieldDefinition(type="boolean", required=False),
    },
    description="Product catalog payloads",
)

# enforcement: "off" (no validation), "warn" (log warnings), "strict" (raise on violation)
etag = client.set_schema("products", schema, enforcement="strict")

# Inspect or remove the schema
current = client.get_schema("products")   # returns None if no schema is defined
client.delete_schema("products")
```

### Infer a schema from existing data

```python
analysis = client.analyze_schema("products", sample_size=1000)
print(analysis.suggested_schema)   # a Schema you can set_schema() directly
```

Schema violations raise `SchemaValidationError` (with a list of per-field errors). If the server reports a stale schema (`412 Precondition Failed`), the SDK auto-refreshes the cache and retries.

## 📊 Performance Comparison

| Feature | Local Qdrant | Aetherfy Vectors |
|---------|-------------|------------------|
| **Global Latency** | Single region, wherever you host it | **Served from the nearest region** |
| **Caching** | None | **Built in** |
| **DevOps Required** | High | **Zero** |
| **Auto-Failover** | Manual | **Automatic** |
| **Global Replication** | Manual | **Automatic** |
| **Analytics** | Limited | **Built-in** |

## 🛠️ Environment Setup

### API Key Configuration

Set your API key using environment variables (recommended):

```bash
# Either of these is read automatically
export AETHERFY_API_KEY="afy_live_your_api_key_here"
export AETHERFY_VECTORS_API_KEY="afy_live_your_api_key_here"
```

Or pass it directly:

```python
client = AetherfyVectorsClient(api_key="afy_live_your_api_key_here")
```

Relevant environment variables:

| Variable | Purpose |
|----------|---------|
| `AETHERFY_API_KEY` | Primary API key |
| `AETHERFY_VECTORS_API_KEY` | Alternative API key (useful when the same process talks to multiple Aetherfy services) |
| `AETHERFY_WORKSPACE` | Used when the client is constructed with `workspace="auto"` (set automatically on deployed agents) |
| `AETHERFY_VECTORS_URL` | Pin the client to a specific endpoint URL. Set automatically by the control-plane on deployed agents. Wins over `api_region=` if both are set. |
| `AETHERFY_VECTORS_API_REGION` | Equivalent to `api_region=` constructor arg. Local-dev / debugging only. |

### Local development across regions

Production agents have `AETHERFY_VECTORS_URL` injected by the control-plane —
that's the URL they reach the regional backend through, and it takes
precedence over `api_region=`. For local development (no env var injected),
you can pin a client to a specific regional endpoint:

```python
client = AetherfyVectorsClient(
    api_key="afy_test_...",
    api_region="eu-central-1",  # 'us-east-1' | 'eu-central-1' | 'ap-southeast-1'
)
```

The first call resolves `api_region` against `GET /api/v1/regions` on the
default global endpoint and caches the result on the client instance.
If both `AETHERFY_VECTORS_URL` and `api_region=` are set, the env var wins
and a warning is logged — that's the production-agent protection rule.

`api_region` selects *which regional endpoint to connect to* — it is a
transport/routing override, not where a collection's data lives. To control
collection placement, pass `regions=` to `create_collection` (see below).

### Python Version Support

- **Minimum**: Python 3.9
- **Recommended**: Python 3.10+
- **Tested**: Python 3.9, 3.10, 3.11, 3.12

## 📚 Complete API Reference

### Collection Management

```python
# Create collection — returns the created Collection (with its resolved
# `regions` echoed back by the server). Pass `regions=[...]` to pin the
# collection to a subset of your scope; omit it to default to your full
# scope. (Distinct from the constructor's `api_region`, which only picks
# the endpoint to connect to.)
collection = client.create_collection(
    collection_name, vectors_config, distance=None, description=None, regions=None
)

# List collections
collections = client.get_collections()

# Get collection info
info = client.get_collection(name)

# Check existence
exists = client.collection_exists(name)

# Delete collection
client.delete_collection(name)
```

### Point Operations

```python
# Insert/update points
client.upsert(collection_name, points)

# Retrieve points
points = client.retrieve(collection_name, ids, with_payload=True, with_vectors=False)

# Delete points
client.delete(collection_name, point_ids_or_filter)

# Count points — count_filter takes a Filter or a plain dict, same as
# search / scroll / delete
count = client.count(collection_name, count_filter=None, exact=True)
```

### Search Operations

```python
# Vector search
results = client.search(
    collection_name,
    query_vector,
    limit=10,
    offset=0,
    query_filter=None,
    with_payload=True,
    with_vectors=False,
    score_threshold=None,
    search_params=None      # engine params, sent verbatim as the body's `params`
)

# Trade latency for recall: a larger hnsw_ef makes the graph walk visit more
# candidates. Omit search_params to keep the tuned server default (hnsw_ef=100).
results = client.search(collection_name, query_vector, search_params={"hnsw_ef": 256})
```

`search_params` is passed through untranslated — the API and Qdrant own the
schema, so the SDK validates nothing and needs no release to track new params.
Note that the server cache key is derived from the request body, so the same
query at a different `hnsw_ef` is a separate cache entry (never a wrong hit).

### Schema Management (Aetherfy-specific)

```python
# Set, fetch, and remove a payload schema.
# enforcement defaults to "off" — pass "warn" or "strict" to have the
# server act on violations.
etag = client.set_schema(collection_name, schema, enforcement="off", description=None)
schema = client.get_schema(collection_name)           # None if not set
client.delete_schema(collection_name)

# Infer a schema from existing data
analysis = client.analyze_schema(collection_name, sample_size=1000)  # 100–10000

# Cache control (rarely needed)
client.refresh_schema(collection_name)
client.clear_schema_cache(collection_name=None)       # None clears all
```

### Usage statistics (Aetherfy-specific)

```python
usage = client.get_usage_stats()

usage.storage_bytes_used     # int  — bytes stored across every collection
usage.storage_limit_bytes    # int | None — None on an unlimited tier
usage.collections_count      # int  — active collections
usage.collections_limit      # int | None — None on an unlimited tier
usage.tier                   # str  — the plan's tier name
usage.active_regions         # list[str] — union of your collections' regions
usage.usage_percentage       # int  — storage %, 0 when there is no limit
```

## 🚨 Error Handling

The SDK provides detailed error handling compatible with qdrant-client:

```python
from aetherfy_vectors.exceptions import (
    AetherfyVectorsException,   # base class for all SDK errors
    AuthenticationError,
    CollectionNotFoundError,
    PointNotFoundError,
    RateLimitExceededError,
    ServiceUnavailableError,
    ValidationError,
    RequestTimeoutError,
    NetworkError,
    SchemaValidationError,
    SchemaNotFoundError,
    CollectionInUseError,
    QuotaExceededError,
)

try:
    results = client.search("nonexistent", [0.1, 0.2, 0.3])
except CollectionNotFoundError as e:
    print(f"Collection not found: {e}")
except AuthenticationError as e:
    print(f"Invalid API key: {e}")
except RateLimitExceededError as e:
    print(f"Rate limit exceeded. Retry after: {e.retry_after}s")
except SchemaValidationError as e:
    for violation in e.errors:
        print(violation)
except QuotaExceededError as e:
    print(f"Quota '{e.quota_type}' exceeded: {e.current}/{e.limit}")
```

## 🔧 Configuration Options

```python
client = AetherfyVectorsClient(
    api_key="your_api_key",                   # Required (or set AETHERFY_API_KEY)
    endpoint="https://vectors.aetherfy.com",  # Optional: Custom endpoint
    timeout=30.0,                             # Optional: Request timeout (seconds)
    workspace=None,                           # Optional: None, a workspace name, or "auto"
                                              #           to read AETHERFY_WORKSPACE
)
```

## 📈 Monitoring & Observability

### Built-in Dashboard Data

```python
# Quota consumption, for a plan-usage dashboard
usage = client.get_usage_stats()

dashboard_data = {
    "storage_bytes": usage.storage_bytes_used,
    "storage_limit_bytes": usage.storage_limit_bytes,   # None = unlimited
    "storage_percent": usage.usage_percentage,
    "collections": usage.collections_count,
    "collections_limit": usage.collections_limit,       # None = unlimited
    "tier": usage.tier,
    "regions": usage.active_regions,
}
```

The fields are the endpoint's own, verbatim — no derived percentages beyond the
one it serves, and no unit conversion. Anything else you want (a collections
percentage, megabytes) is a calculation at your call site, where the "unlimited"
sentinels are yours to handle.

Aetherfy does not expose latency or cache-hit telemetry through this SDK. Measure
request latency at your own call site — it is the only number that reflects what
your application actually experienced.

### Health Checks

```python
def health_check():
    try:
        collections = client.get_collections()
        return {"status": "healthy", "collections": len(collections)}
    except Exception as e:
        return {"status": "unhealthy", "error": str(e)}
```

## 🔗 Migration Guide

### Step-by-Step Migration

1. **Install aetherfy-vectors**:
   ```bash
   pip install aetherfy-vectors
   ```

2. **Get your API key** from the [Aetherfy dashboard](https://app.aetherfy.com/dashboard/settings/api-keys)

3. **Update imports**:
   ```python
   # from qdrant_client import QdrantClient
   from aetherfy_vectors import AetherfyVectorsClient
   ```

4. **Update initialization**:
   ```python
   # client = QdrantClient(host="localhost", port=6333)
   client = AetherfyVectorsClient(api_key="your_api_key")
   ```

5. **Test existing functionality** (should work unchanged!)

6. **Optional**: Add analytics calls for insights

7. **Deploy** and enjoy global performance! 🚀

### Migration Compatibility

| qdrant-client Method | aetherfy-vectors | Compatible |
|---------------------|------------------|------------|
| `create_collection()` | ✅ | 100% |
| `get_collections()` | ✅ | 100% |
| `upsert()` | ✅ | 100% |
| `search()` | ✅ | 100% |
| `retrieve()` | ✅ | 100% |
| `delete()` | ✅ | 100% |
| `count()` | ✅ | 100% |

**Plus additional analytics methods unique to Aetherfy!**

## 🤝 Contributing

We welcome contributions! Open an issue or a pull request on
[GitHub](https://github.com/l-td/aetherfy-vectors-python-sdk).

### Development Setup

```bash
# Clone the repository
git clone https://github.com/l-td/aetherfy-vectors-python-sdk.git
cd aetherfy-vectors-python-sdk

# Install development dependencies
pip install -r requirements-dev.txt

# Run tests
pytest

# Format code
black aetherfy_vectors/
```

## 📄 License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## 🆘 Support

- **Documentation**: [https://docs.aetherfy.com/vectors](https://docs.aetherfy.com/vectors)
- **API Reference**: [https://docs.aetherfy.com/vectors/api](https://docs.aetherfy.com/vectors/api)
- **SDK Reference**: [https://docs.aetherfy.com/vectors/sdk](https://docs.aetherfy.com/vectors/sdk)
- **Issues**: [GitHub Issues](https://github.com/l-td/aetherfy-vectors-python-sdk/issues)
- **Email**: [developers@aetherfy.com](mailto:developers@aetherfy.com)

## 🌟 Why Choose Aetherfy Vectors?

### For Solo Developers
- **Zero setup time** - start building immediately
- **Predictable pricing** - no surprise infrastructure costs
- **Global reach** - your users get fast responses worldwide

### For Teams
- **No DevOps overhead** - focus on your product, not infrastructure
- **Built-in monitoring** - comprehensive analytics out of the box
- **Reliable performance** - automatic failover

### For Enterprises
- **Global scalability** - handles millions of vectors effortlessly
- **Security first** - enterprise-grade authentication and audit logs
- **Cost effective** - pay only for what you use, no idle infrastructure

---

**Ready to experience global vector search?** [Get your API key](https://app.aetherfy.com/dashboard/settings/api-keys) and migrate in minutes! 🚀
