huggingface-api-haystack 0.5.0__tar.gz → 0.6.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 (36) hide show
  1. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/PKG-INFO +6 -2
  2. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/README.md +4 -1
  3. huggingface_api_haystack-0.6.0/docker-compose.yml +14 -0
  4. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/pydoc/config_docusaurus.yml +2 -0
  5. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/pyproject.toml +1 -1
  6. huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/__init__.py +14 -0
  7. huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_document_embedder.py +199 -0
  8. huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_embedding_utils.py +72 -0
  9. huggingface_api_haystack-0.6.0/src/haystack_integrations/components/embedders/huggingface_api/sparse_text_embedder.py +116 -0
  10. huggingface_api_haystack-0.6.0/tests/test_sparse_document_embedder.py +342 -0
  11. huggingface_api_haystack-0.6.0/tests/test_sparse_text_embedder.py +263 -0
  12. huggingface_api_haystack-0.5.0/src/haystack_integrations/components/embedders/huggingface_api/__init__.py +0 -7
  13. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/.gitignore +0 -0
  14. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/CHANGELOG.md +0 -0
  15. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/LICENSE.txt +0 -0
  16. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/huggingface_api/__init__.py +0 -0
  17. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/huggingface_api/utils.py +0 -0
  18. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/common/py.typed +0 -0
  19. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/huggingface_api/document_embedder.py +0 -0
  20. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/huggingface_api/text_embedder.py +0 -0
  21. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/embedders/py.typed +0 -0
  22. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/__init__.py +0 -0
  23. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/chat/__init__.py +0 -0
  24. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/huggingface_api/chat/chat_generator.py +0 -0
  25. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/generators/py.typed +0 -0
  26. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/huggingface_api/__init__.py +0 -0
  27. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/huggingface_api/ranker.py +0 -0
  28. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/src/haystack_integrations/components/rankers/py.typed +0 -0
  29. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/__init__.py +0 -0
  30. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/conftest.py +0 -0
  31. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_chat_generator.py +0 -0
  32. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_document_embedder.py +0 -0
  33. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_files/apple.jpg +0 -0
  34. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_ranker.py +0 -0
  35. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_text_embedder.py +0 -0
  36. {huggingface_api_haystack-0.5.0 → huggingface_api_haystack-0.6.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: huggingface-api-haystack
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Haystack integration for Hugging Face API
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/huggingface_api#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
20
20
  Classifier: Programming Language :: Python :: Implementation :: PyPy
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: haystack-ai>=2.30.0
23
+ Requires-Dist: httpx>=0.27.0
23
24
  Requires-Dist: huggingface-hub[inference]>=0.35.0
24
25
  Description-Content-Type: text/markdown
25
26
 
@@ -37,4 +38,7 @@ Description-Content-Type: text/markdown
37
38
 
38
39
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
39
40
 
40
- To run integration tests locally, you need to export the `HF_TOKEN` environment variable.
41
+ To run integration tests locally, you need a Docker container running a Text Embeddings Inference (TEI) server with
42
+ a sparse embedding model. Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
43
+
44
+ The tests that call the Hugging Face Inference API are skipped unless `HF_TOKEN` is set as an evironment variable.
@@ -12,4 +12,7 @@
12
12
 
13
13
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
14
14
 
15
- To run integration tests locally, you need to export the `HF_TOKEN` environment variable.
15
+ To run integration tests locally, you need a Docker container running a Text Embeddings Inference (TEI) server with
16
+ a sparse embedding model. Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
17
+
18
+ The tests that call the Hugging Face Inference API are skipped unless `HF_TOKEN` is set as an evironment variable.
@@ -0,0 +1,14 @@
1
+ services:
2
+ tei-sparse:
3
+ image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.3
4
+ command: ["--model-id", "opensearch-project/opensearch-neural-sparse-encoding-doc-v2-mini", "--pooling", "splade"]
5
+ ports:
6
+ - "8080:80"
7
+ environment:
8
+ - HF_TOKEN
9
+ volumes:
10
+ - tei-data:/data
11
+ restart: no
12
+
13
+ volumes:
14
+ tei-data:
@@ -1,6 +1,8 @@
1
1
  loaders:
2
2
  - modules:
3
3
  - haystack_integrations.components.embedders.huggingface_api.document_embedder
4
+ - haystack_integrations.components.embedders.huggingface_api.sparse_document_embedder
5
+ - haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder
4
6
  - haystack_integrations.components.embedders.huggingface_api.text_embedder
5
7
  - haystack_integrations.components.generators.huggingface_api.chat.chat_generator
6
8
  - haystack_integrations.components.rankers.huggingface_api.ranker
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: Implementation :: CPython",
24
24
  "Programming Language :: Python :: Implementation :: PyPy",
25
25
  ]
26
- dependencies = ["haystack-ai>=2.30.0", "huggingface_hub[inference]>=0.35.0"]
26
+ dependencies = ["haystack-ai>=2.30.0", "httpx>=0.27.0", "huggingface_hub[inference]>=0.35.0"]
27
27
 
28
28
  [project.urls]
29
29
  Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/huggingface_api#readme"
@@ -0,0 +1,14 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ from .document_embedder import HuggingFaceAPIDocumentEmbedder
5
+ from .sparse_document_embedder import HuggingFaceAPISparseDocumentEmbedder
6
+ from .sparse_text_embedder import HuggingFaceAPISparseTextEmbedder
7
+ from .text_embedder import HuggingFaceAPITextEmbedder
8
+
9
+ __all__ = [
10
+ "HuggingFaceAPIDocumentEmbedder",
11
+ "HuggingFaceAPISparseDocumentEmbedder",
12
+ "HuggingFaceAPISparseTextEmbedder",
13
+ "HuggingFaceAPITextEmbedder",
14
+ ]
@@ -0,0 +1,199 @@
1
+ # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from asyncio import Semaphore, gather
6
+ from dataclasses import replace
7
+ from itertools import chain
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from haystack import Document, component, default_from_dict, default_to_dict
12
+ from haystack.dataclasses import SparseEmbedding
13
+ from haystack.utils import Secret
14
+ from haystack.utils.url_validation import is_valid_http_url
15
+ from tqdm import tqdm
16
+
17
+ from .sparse_embedding_utils import _build_client_kwargs, _embed_sparse, _embed_sparse_async
18
+
19
+
20
+ @component
21
+ class HuggingFaceAPISparseDocumentEmbedder:
22
+ """
23
+ Embeds Documents into sparse vectors using a Hugging Face Text Embeddings Inference (TEI) server.
24
+
25
+ The component batches requests and returns copies of the input Documents with `sparse_embedding` set.
26
+
27
+ ```python
28
+ from haystack import Document
29
+ from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseDocumentEmbedder
30
+
31
+ embedder = HuggingFaceAPISparseDocumentEmbedder(api_base_url="http://localhost:8080")
32
+ documents = embedder.run([Document(content="Sparse retrieval")])["documents"]
33
+ print(documents[0].sparse_embedding)
34
+ ```
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ api_base_url: str = "http://localhost:8080",
41
+ token: Secret | None = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
42
+ prefix: str = "",
43
+ suffix: str = "",
44
+ batch_size: int = 32,
45
+ progress_bar: bool = True,
46
+ meta_fields_to_embed: list[str] | None = None,
47
+ embedding_separator: str = "\n",
48
+ timeout: float | None = 30.0,
49
+ headers: dict[str, str] | None = None,
50
+ concurrency_limit: int = 4,
51
+ ) -> None:
52
+ """
53
+ Create a sparse Document embedder backed by TEI.
54
+
55
+ :param api_base_url: Base URL of the TEI server.
56
+ :param token: Token sent to TEI as HTTP bearer authorization, if set.
57
+ :param prefix: A string to add before each prepared Document text.
58
+ :param suffix: A string to add after each prepared Document text.
59
+ :param batch_size: Number of Documents sent in each request.
60
+ :param progress_bar: If `True`, show a progress bar while embedding.
61
+ :param meta_fields_to_embed: Metadata fields to embed before the Document content.
62
+ :param embedding_separator: Separator for metadata fields and Document content.
63
+ :param timeout: HTTP request timeout in seconds. Set to `None` to disable it.
64
+ :param headers: Additional HTTP headers to send with each request.
65
+ :param concurrency_limit: Maximum concurrent requests made by `run_async`.
66
+ :raises ValueError: If `api_base_url` is invalid or a numeric parameter is not positive.
67
+ """
68
+ if not is_valid_http_url(api_base_url):
69
+ msg = f"api_base_url must be a valid HTTP URL, but got {api_base_url}"
70
+ raise ValueError(msg)
71
+ if batch_size <= 0:
72
+ msg = f"batch_size must be > 0, but got {batch_size}"
73
+ raise ValueError(msg)
74
+ if concurrency_limit <= 0:
75
+ msg = f"concurrency_limit must be > 0, but got {concurrency_limit}"
76
+ raise ValueError(msg)
77
+
78
+ self.api_base_url = api_base_url
79
+ self.token = token
80
+ self.prefix = prefix
81
+ self.suffix = suffix
82
+ self.batch_size = batch_size
83
+ self.progress_bar = progress_bar
84
+ self.meta_fields_to_embed = meta_fields_to_embed or []
85
+ self.embedding_separator = embedding_separator
86
+ self.timeout = timeout
87
+ self.headers = headers or {}
88
+ self.concurrency_limit = concurrency_limit
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Serialize this component to a dictionary."""
92
+ return default_to_dict(
93
+ self,
94
+ api_base_url=self.api_base_url,
95
+ token=self.token,
96
+ prefix=self.prefix,
97
+ suffix=self.suffix,
98
+ batch_size=self.batch_size,
99
+ progress_bar=self.progress_bar,
100
+ meta_fields_to_embed=self.meta_fields_to_embed,
101
+ embedding_separator=self.embedding_separator,
102
+ timeout=self.timeout,
103
+ headers=self.headers,
104
+ concurrency_limit=self.concurrency_limit,
105
+ )
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: dict[str, Any]) -> "HuggingFaceAPISparseDocumentEmbedder":
109
+ """Deserialize this component from a dictionary."""
110
+ return default_from_dict(cls, data)
111
+
112
+ def _client_kwargs(self) -> dict[str, Any]:
113
+ return _build_client_kwargs(
114
+ api_base_url=self.api_base_url, timeout=self.timeout, headers=self.headers, token=self.token
115
+ )
116
+
117
+ def _prepare_texts_to_embed(self, documents: list[Document]) -> list[str]:
118
+ texts = []
119
+ for document in documents:
120
+ meta = [
121
+ str(document.meta[field])
122
+ for field in self.meta_fields_to_embed
123
+ if field in document.meta and document.meta[field] is not None
124
+ ]
125
+ texts.append(self.prefix + self.embedding_separator.join([*meta, document.content or ""]) + self.suffix)
126
+ return texts
127
+
128
+ def _embed_batches(self, client: httpx.Client, texts: list[str]) -> list[SparseEmbedding]:
129
+ embeddings = []
130
+ for start in tqdm(
131
+ range(0, len(texts), self.batch_size),
132
+ disable=not self.progress_bar,
133
+ desc="Calculating sparse embeddings",
134
+ ):
135
+ embeddings.extend(_embed_sparse(client=client, inputs=texts[start : start + self.batch_size]))
136
+ return embeddings
137
+
138
+ async def _embed_batches_async(self, client: httpx.AsyncClient, texts: list[str]) -> list[SparseEmbedding]:
139
+ semaphore = Semaphore(self.concurrency_limit)
140
+ batches = [texts[start : start + self.batch_size] for start in range(0, len(texts), self.batch_size)]
141
+ progress = tqdm(total=len(batches), disable=not self.progress_bar, desc="Calculating sparse embeddings")
142
+
143
+ async def embed_batch(batch: list[str]) -> list[SparseEmbedding]:
144
+ async with semaphore:
145
+ result = await _embed_sparse_async(client=client, inputs=batch)
146
+ progress.update(1)
147
+ return result
148
+
149
+ try:
150
+ return list(chain.from_iterable(await gather(*(embed_batch(batch) for batch in batches))))
151
+ finally:
152
+ progress.close()
153
+
154
+ @staticmethod
155
+ def _validate_documents(documents: list[Document]) -> None:
156
+ if not isinstance(documents, list) or any(not isinstance(document, Document) for document in documents):
157
+ msg = (
158
+ "HuggingFaceAPISparseDocumentEmbedder expects a list of Documents as input. "
159
+ "To embed a string, use HuggingFaceAPISparseTextEmbedder."
160
+ )
161
+ raise TypeError(msg)
162
+
163
+ @component.output_types(documents=list[Document])
164
+ def run(self, documents: list[Document]) -> dict[str, list[Document]]:
165
+ """
166
+ Embed a list of Documents.
167
+
168
+ :param documents: Documents to embed.
169
+ :returns: Copies of the Documents with sparse embeddings.
170
+ """
171
+ self._validate_documents(documents)
172
+ texts = self._prepare_texts_to_embed(documents)
173
+ with httpx.Client(**self._client_kwargs()) as client:
174
+ embeddings = self._embed_batches(client, texts)
175
+ return {
176
+ "documents": [
177
+ replace(document, sparse_embedding=embedding)
178
+ for document, embedding in zip(documents, embeddings, strict=True)
179
+ ]
180
+ }
181
+
182
+ @component.output_types(documents=list[Document])
183
+ async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
184
+ """
185
+ Embed a list of Documents asynchronously.
186
+
187
+ :param documents: Documents to embed.
188
+ :returns: Copies of the Documents with sparse embeddings.
189
+ """
190
+ self._validate_documents(documents)
191
+ texts = self._prepare_texts_to_embed(documents)
192
+ async with httpx.AsyncClient(**self._client_kwargs()) as client:
193
+ embeddings = await self._embed_batches_async(client, texts)
194
+ return {
195
+ "documents": [
196
+ replace(document, sparse_embedding=embedding)
197
+ for document, embedding in zip(documents, embeddings, strict=True)
198
+ ]
199
+ }
@@ -0,0 +1,72 @@
1
+ # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from haystack.dataclasses import SparseEmbedding
9
+ from haystack.utils import Secret
10
+
11
+
12
+ def _build_client_kwargs(
13
+ *, api_base_url: str, timeout: float | None, headers: dict[str, str], token: Secret | None
14
+ ) -> dict[str, Any]:
15
+ """
16
+ Build the `httpx` client options shared by the sync and async clients.
17
+
18
+ `headers` is applied after the token, so an `Authorization` header passed at initialization wins over the one
19
+ derived from `token`. Otherwise, an explicit header would be silently replaced whenever `HF_TOKEN` happens to be
20
+ set in the environment.
21
+ """
22
+ request_headers: dict[str, str] = {}
23
+ if token and (token_value := token.resolve_value()):
24
+ request_headers["Authorization"] = f"Bearer {token_value}"
25
+ request_headers.update(headers)
26
+ return {"base_url": f"{api_base_url.rstrip('/')}/", "timeout": timeout, "headers": request_headers}
27
+
28
+
29
+ def _parse_sparse_embeddings(result: Any, expected_count: int) -> list[SparseEmbedding]:
30
+ if not isinstance(result, list) or len(result) != expected_count:
31
+ msg = f"Expected one sparse embedding per input ({expected_count}), got {result!r}"
32
+ raise ValueError(msg)
33
+
34
+ embeddings = []
35
+ for sparse_values in result:
36
+ if not isinstance(sparse_values, list):
37
+ msg = f"Expected each sparse embedding to be a list, got {sparse_values!r}"
38
+ raise ValueError(msg)
39
+
40
+ indices = []
41
+ values = []
42
+ for sparse_value in sparse_values:
43
+ if not isinstance(sparse_value, dict) or "index" not in sparse_value or "value" not in sparse_value:
44
+ msg = f"Expected sparse values with 'index' and 'value' fields, got {sparse_value!r}"
45
+ raise ValueError(msg)
46
+ index = sparse_value["index"]
47
+ value = sparse_value["value"]
48
+ if (
49
+ not isinstance(index, int)
50
+ or isinstance(index, bool)
51
+ or not isinstance(value, int | float)
52
+ or isinstance(value, bool)
53
+ ):
54
+ msg = f"Invalid sparse value returned by TEI: {sparse_value!r}"
55
+ raise ValueError(msg)
56
+ indices.append(index)
57
+ values.append(float(value))
58
+
59
+ embeddings.append(SparseEmbedding(indices=indices, values=values))
60
+ return embeddings
61
+
62
+
63
+ def _embed_sparse(*, client: httpx.Client, inputs: str | list[str]) -> list[SparseEmbedding]:
64
+ response = client.post("embed_sparse", json={"inputs": inputs})
65
+ response.raise_for_status()
66
+ return _parse_sparse_embeddings(response.json(), 1 if isinstance(inputs, str) else len(inputs))
67
+
68
+
69
+ async def _embed_sparse_async(*, client: httpx.AsyncClient, inputs: str | list[str]) -> list[SparseEmbedding]:
70
+ response = await client.post("embed_sparse", json={"inputs": inputs})
71
+ response.raise_for_status()
72
+ return _parse_sparse_embeddings(response.json(), 1 if isinstance(inputs, str) else len(inputs))
@@ -0,0 +1,116 @@
1
+ # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from haystack import component, default_from_dict, default_to_dict
9
+ from haystack.dataclasses import SparseEmbedding
10
+ from haystack.utils import Secret
11
+ from haystack.utils.url_validation import is_valid_http_url
12
+
13
+ from .sparse_embedding_utils import _build_client_kwargs, _embed_sparse, _embed_sparse_async
14
+
15
+
16
+ @component
17
+ class HuggingFaceAPISparseTextEmbedder:
18
+ """
19
+ Embeds text into a sparse vector using a Hugging Face Text Embeddings Inference (TEI) server.
20
+
21
+ The TEI server must be running a sparse embedding model and expose the `/embed_sparse` endpoint.
22
+
23
+ ```python
24
+ from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseTextEmbedder
25
+
26
+ embedder = HuggingFaceAPISparseTextEmbedder(api_base_url="http://localhost:8080")
27
+ result = embedder.run("What is sparse retrieval?")
28
+ print(result["sparse_embedding"])
29
+ ```
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ api_base_url: str = "http://localhost:8080",
36
+ token: Secret | None = Secret.from_env_var(["HF_API_TOKEN", "HF_TOKEN"], strict=False),
37
+ prefix: str = "",
38
+ suffix: str = "",
39
+ timeout: float | None = 30.0,
40
+ headers: dict[str, str] | None = None,
41
+ ) -> None:
42
+ """
43
+ Create a sparse text embedder backed by TEI.
44
+
45
+ :param api_base_url: Base URL of the TEI server.
46
+ :param token: Token sent to TEI as HTTP bearer authorization, if set.
47
+ :param prefix: A string to add before the text.
48
+ :param suffix: A string to add after the text.
49
+ :param timeout: HTTP request timeout in seconds. Set to `None` to disable it.
50
+ :param headers: Additional HTTP headers to send with each request.
51
+ :raises ValueError: If `api_base_url` is not a valid HTTP URL.
52
+ """
53
+ if not is_valid_http_url(api_base_url):
54
+ msg = f"api_base_url must be a valid HTTP URL, but got {api_base_url}"
55
+ raise ValueError(msg)
56
+ self.api_base_url = api_base_url
57
+ self.token = token
58
+ self.prefix = prefix
59
+ self.suffix = suffix
60
+ self.timeout = timeout
61
+ self.headers = headers or {}
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Serialize this component to a dictionary."""
65
+ return default_to_dict(
66
+ self,
67
+ api_base_url=self.api_base_url,
68
+ token=self.token,
69
+ prefix=self.prefix,
70
+ suffix=self.suffix,
71
+ timeout=self.timeout,
72
+ headers=self.headers,
73
+ )
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> "HuggingFaceAPISparseTextEmbedder":
77
+ """Deserialize this component from a dictionary."""
78
+ return default_from_dict(cls, data)
79
+
80
+ def _client_kwargs(self) -> dict[str, Any]:
81
+ return _build_client_kwargs(
82
+ api_base_url=self.api_base_url, timeout=self.timeout, headers=self.headers, token=self.token
83
+ )
84
+
85
+ def _prepare_input(self, text: str) -> str:
86
+ if not isinstance(text, str):
87
+ msg = (
88
+ "HuggingFaceAPISparseTextEmbedder expects a string as input. "
89
+ "To embed Documents, use HuggingFaceAPISparseDocumentEmbedder."
90
+ )
91
+ raise TypeError(msg)
92
+ return self.prefix + text + self.suffix
93
+
94
+ @component.output_types(sparse_embedding=SparseEmbedding)
95
+ def run(self, text: str) -> dict[str, SparseEmbedding]:
96
+ """
97
+ Embed a single string.
98
+
99
+ :param text: Text to embed.
100
+ :returns: The sparse embedding of the input text.
101
+ """
102
+ with httpx.Client(**self._client_kwargs()) as client:
103
+ embeddings = _embed_sparse(client=client, inputs=self._prepare_input(text))
104
+ return {"sparse_embedding": embeddings[0]}
105
+
106
+ @component.output_types(sparse_embedding=SparseEmbedding)
107
+ async def run_async(self, text: str) -> dict[str, SparseEmbedding]:
108
+ """
109
+ Embed a single string asynchronously.
110
+
111
+ :param text: Text to embed.
112
+ :returns: The sparse embedding of the input text.
113
+ """
114
+ async with httpx.AsyncClient(**self._client_kwargs()) as client:
115
+ embeddings = await _embed_sparse_async(client=client, inputs=self._prepare_input(text))
116
+ return {"sparse_embedding": embeddings[0]}
@@ -0,0 +1,342 @@
1
+ # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import asyncio
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from typing import Any
9
+ from unittest.mock import MagicMock, call, patch
10
+
11
+ import httpx
12
+ import pytest
13
+ from haystack import Document
14
+ from haystack.dataclasses import SparseEmbedding
15
+ from haystack.utils import Secret
16
+
17
+ from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseDocumentEmbedder
18
+
19
+ API_BASE_URL = "http://localhost:8080"
20
+ MODULE = "haystack_integrations.components.embedders.huggingface_api.sparse_document_embedder"
21
+
22
+
23
+ def sparse_response(data: Any) -> MagicMock:
24
+ response = MagicMock(spec=httpx.Response)
25
+ response.json.return_value = data
26
+ return response
27
+
28
+
29
+ @contextmanager
30
+ def patched_client(*, is_async: bool = False) -> Iterator[tuple[MagicMock, MagicMock]]:
31
+ """
32
+ Patch the `httpx` client constructor and yield the (client, constructor) mocks.
33
+
34
+ The component builds a client per call, so tests reach the client through the constructor rather than through
35
+ an attribute. `__enter__`/`__aenter__` yield the same mock, so `client.post` assertions read naturally.
36
+ """
37
+ name = "AsyncClient" if is_async else "Client"
38
+ client = MagicMock(spec=getattr(httpx, name))
39
+ if is_async:
40
+ client.__aenter__.return_value = client
41
+ client.__aexit__.return_value = False
42
+ else:
43
+ client.__enter__.return_value = client
44
+ client.__exit__.return_value = False
45
+ with patch(f"{MODULE}.httpx.{name}", return_value=client) as constructor:
46
+ yield client, constructor
47
+
48
+
49
+ class TestHuggingFaceAPISparseDocumentEmbedder:
50
+ @pytest.mark.parametrize("api_base_url", ["not-a-url", "file:///path", "localhost:8080"])
51
+ def test_init_rejects_invalid_api_base_url(self, api_base_url: str) -> None:
52
+ with pytest.raises(ValueError, match="api_base_url must be a valid HTTP URL"):
53
+ HuggingFaceAPISparseDocumentEmbedder(api_base_url=api_base_url)
54
+
55
+ @pytest.mark.parametrize(("parameter", "value"), [("batch_size", 0), ("batch_size", -2), ("concurrency_limit", 0)])
56
+ def test_init_rejects_non_positive_numeric_options(self, parameter: str, value: int) -> None:
57
+ with pytest.raises(ValueError, match=f"{parameter} must be > 0"):
58
+ HuggingFaceAPISparseDocumentEmbedder(**{parameter: value})
59
+
60
+ def test_init_defaults(self) -> None:
61
+ embedder = HuggingFaceAPISparseDocumentEmbedder()
62
+
63
+ assert embedder.api_base_url == "http://localhost:8080"
64
+ assert embedder.batch_size == 32
65
+ assert embedder.progress_bar is True
66
+ assert embedder.meta_fields_to_embed == []
67
+ assert embedder.embedding_separator == "\n"
68
+ assert embedder.timeout == 30.0
69
+ assert embedder.headers == {}
70
+ assert embedder.concurrency_limit == 4
71
+
72
+ def test_to_dict_and_from_dict_preserve_configuration_and_secret(self, monkeypatch: pytest.MonkeyPatch) -> None:
73
+ monkeypatch.setenv("CUSTOM_HF_TOKEN", "secret")
74
+ embedder = HuggingFaceAPISparseDocumentEmbedder(
75
+ api_base_url="https://tei.example.test",
76
+ token=Secret.from_env_var("CUSTOM_HF_TOKEN"),
77
+ prefix="passage: ",
78
+ suffix="!",
79
+ batch_size=2,
80
+ progress_bar=False,
81
+ meta_fields_to_embed=["title"],
82
+ embedding_separator=" | ",
83
+ timeout=None,
84
+ headers={"X-Test": "yes"},
85
+ concurrency_limit=3,
86
+ )
87
+
88
+ data = embedder.to_dict()
89
+
90
+ assert data["init_parameters"]["token"] == {
91
+ "type": "env_var",
92
+ "env_vars": ["CUSTOM_HF_TOKEN"],
93
+ "strict": True,
94
+ }
95
+ assert data["init_parameters"] == {
96
+ "api_base_url": "https://tei.example.test",
97
+ "token": {"type": "env_var", "env_vars": ["CUSTOM_HF_TOKEN"], "strict": True},
98
+ "prefix": "passage: ",
99
+ "suffix": "!",
100
+ "batch_size": 2,
101
+ "progress_bar": False,
102
+ "meta_fields_to_embed": ["title"],
103
+ "embedding_separator": " | ",
104
+ "timeout": None,
105
+ "headers": {"X-Test": "yes"},
106
+ "concurrency_limit": 3,
107
+ }
108
+ restored = HuggingFaceAPISparseDocumentEmbedder.from_dict(data)
109
+
110
+ assert restored.api_base_url == embedder.api_base_url
111
+ assert restored.prefix == "passage: "
112
+ assert restored.suffix == "!"
113
+ assert restored.batch_size == 2
114
+ assert restored.progress_bar is False
115
+ assert restored.meta_fields_to_embed == ["title"]
116
+ assert restored.embedding_separator == " | "
117
+ assert restored.timeout is None
118
+ assert restored.headers == {"X-Test": "yes"}
119
+ assert restored.concurrency_limit == 3
120
+ assert restored.token is not None and restored.token.resolve_value() == "secret"
121
+
122
+ def test_token_secret_cannot_be_serialized(self) -> None:
123
+ embedder = HuggingFaceAPISparseDocumentEmbedder(token=Secret.from_token("do-not-serialize"))
124
+
125
+ with pytest.raises(ValueError, match="Cannot serialize token-based secret"):
126
+ embedder.to_dict()
127
+
128
+ def test_prepare_texts_handles_metadata_prefix_suffix_and_missing_content(self) -> None:
129
+ documents = [
130
+ Document(content="body", meta={"title": "Title", "priority": 3, "ignored": "no"}),
131
+ Document(content=None, meta={"title": None, "priority": 0}),
132
+ Document(content="only body", meta={}),
133
+ ]
134
+ embedder = HuggingFaceAPISparseDocumentEmbedder(
135
+ prefix="<p>", suffix="</p>", meta_fields_to_embed=["title", "priority"], embedding_separator=" | "
136
+ )
137
+
138
+ assert embedder._prepare_texts_to_embed(documents) == [
139
+ "<p>Title | 3 | body</p>",
140
+ "<p>0 | </p>",
141
+ "<p>only body</p>",
142
+ ]
143
+
144
+ @pytest.mark.parametrize("documents", [None, "document", [1, 2], [Document(content="valid"), "invalid"]])
145
+ def test_run_rejects_invalid_and_mixed_inputs(self, documents: Any) -> None:
146
+ with pytest.raises(TypeError, match="expects a list of Documents"):
147
+ HuggingFaceAPISparseDocumentEmbedder(progress_bar=False).run(documents)
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_run_async_rejects_mixed_inputs(self) -> None:
151
+ with pytest.raises(TypeError, match="expects a list of Documents"):
152
+ await HuggingFaceAPISparseDocumentEmbedder(progress_bar=False).run_async([Document(content="ok"), None])
153
+
154
+ def test_empty_list_returns_without_http_request(self) -> None:
155
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
156
+
157
+ with patched_client() as (client, _):
158
+ result = embedder.run([])
159
+
160
+ assert result == {"documents": []}
161
+ client.post.assert_not_called()
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_empty_list_async_returns_without_http_request(self) -> None:
165
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
166
+
167
+ with patched_client(is_async=True) as (client, _):
168
+ result = await embedder.run_async([])
169
+
170
+ assert result == {"documents": []}
171
+ client.post.assert_not_called()
172
+
173
+ def test_run_batches_requests_preserves_order_and_copies_documents(self) -> None:
174
+ documents = [Document(content=f"doc {number}", meta={"number": number}) for number in range(5)]
175
+ responses = [
176
+ sparse_response([[{"index": 10, "value": 1}], [{"index": 11, "value": 2}]]),
177
+ sparse_response([[{"index": 12, "value": 3}], [{"index": 13, "value": 4}]]),
178
+ sparse_response([[{"index": 14, "value": 5}]]),
179
+ ]
180
+ embedder = HuggingFaceAPISparseDocumentEmbedder(
181
+ api_base_url="http://tei:80/root/",
182
+ token=Secret.from_token("token"),
183
+ prefix="passage: ",
184
+ batch_size=2,
185
+ progress_bar=False,
186
+ timeout=6,
187
+ headers={"X-Tenant": "tenant"},
188
+ )
189
+
190
+ with patched_client() as (client, constructor):
191
+ client.post.side_effect = responses
192
+ result = embedder.run(documents)
193
+
194
+ constructor.assert_called_once_with(
195
+ base_url="http://tei:80/root/",
196
+ timeout=6,
197
+ headers={"Authorization": "Bearer token", "X-Tenant": "tenant"},
198
+ )
199
+ assert client.post.call_args_list == [
200
+ call("embed_sparse", json={"inputs": ["passage: doc 0", "passage: doc 1"]}),
201
+ call("embed_sparse", json={"inputs": ["passage: doc 2", "passage: doc 3"]}),
202
+ call("embed_sparse", json={"inputs": ["passage: doc 4"]}),
203
+ ]
204
+ output = result["documents"]
205
+ assert [document.sparse_embedding.indices for document in output] == [[10], [11], [12], [13], [14]]
206
+ assert [document.sparse_embedding.values for document in output] == [[1.0], [2.0], [3.0], [4.0], [5.0]]
207
+ assert all(new is not original for original, new in zip(documents, output, strict=True))
208
+ assert all(original.sparse_embedding is None for original in documents)
209
+ assert [document.meta for document in output] == [document.meta for document in documents]
210
+
211
+ @pytest.mark.asyncio
212
+ async def test_run_async_batches_with_one_client_and_preserves_batch_order(self) -> None:
213
+ async def post(_url: str, *, json: dict[str, list[str]]) -> MagicMock:
214
+ inputs = json["inputs"]
215
+ # Let the second batch finish first; gather must still preserve input order.
216
+ await asyncio.sleep(0.01 if inputs[0] == "doc 0" else 0)
217
+ offset = int(inputs[0].split()[-1])
218
+ return sparse_response([[{"index": offset + position, "value": 1}] for position, _ in enumerate(inputs)])
219
+
220
+ documents = [Document(content=f"doc {number}") for number in range(4)]
221
+ embedder = HuggingFaceAPISparseDocumentEmbedder(
222
+ api_base_url="https://tei.test/", batch_size=2, progress_bar=False, timeout=None, headers={"X-Test": "yes"}
223
+ )
224
+
225
+ with patched_client(is_async=True) as (client, constructor):
226
+ client.post.side_effect = post
227
+ result = await embedder.run_async(documents)
228
+
229
+ # Both batches share the single client opened for this call.
230
+ constructor.assert_called_once_with(base_url="https://tei.test/", timeout=None, headers={"X-Test": "yes"})
231
+ assert client.post.await_args_list == [
232
+ call("embed_sparse", json={"inputs": ["doc 0", "doc 1"]}),
233
+ call("embed_sparse", json={"inputs": ["doc 2", "doc 3"]}),
234
+ ]
235
+ assert [document.sparse_embedding.indices for document in result["documents"]] == [[0], [1], [2], [3]]
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_run_async_builds_and_closes_a_client_per_call(self) -> None:
239
+ """
240
+ The component must not hold on to an `httpx.AsyncClient`.
241
+
242
+ A cached client binds its keep-alive connection pool to the first event loop that used it, so a component
243
+ reused under a second `asyncio.run` would fail with `RuntimeError: Event loop is closed`.
244
+ """
245
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
246
+
247
+ with patched_client(is_async=True) as (client, constructor):
248
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
249
+ await embedder.run_async([Document(content="one")])
250
+ await embedder.run_async([Document(content="two")])
251
+
252
+ assert constructor.call_count == 2
253
+ assert client.__aexit__.await_count == 2
254
+ assert not hasattr(embedder, "_async_client")
255
+
256
+ def test_run_builds_and_closes_a_client_per_call(self) -> None:
257
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
258
+
259
+ with patched_client() as (client, constructor):
260
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
261
+ embedder.run([Document(content="one")])
262
+ embedder.run([Document(content="two")])
263
+
264
+ assert constructor.call_count == 2
265
+ assert client.__exit__.call_count == 2
266
+ assert not hasattr(embedder, "_client")
267
+
268
+ def test_explicit_authorization_header_wins_over_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
269
+ """An explicit header must not be replaced by a token that only happens to be set in the environment."""
270
+ monkeypatch.delenv("HF_API_TOKEN", raising=False)
271
+ monkeypatch.setenv("HF_TOKEN", "env-token")
272
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False, headers={"Authorization": "Basic test-key"})
273
+
274
+ with patched_client() as (client, constructor):
275
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
276
+ embedder.run([Document(content="one")])
277
+
278
+ assert constructor.call_args.kwargs["headers"] == {"Authorization": "Basic test-key"}
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_async_concurrency_limit_is_respected(self) -> None:
282
+ active = 0
283
+ maximum_active = 0
284
+
285
+ async def embed_batch(**kwargs: Any) -> list[SparseEmbedding]:
286
+ nonlocal active, maximum_active
287
+ active += 1
288
+ maximum_active = max(maximum_active, active)
289
+ await asyncio.sleep(0.01)
290
+ active -= 1
291
+ return [SparseEmbedding(indices=[int(text)], values=[1.0]) for text in kwargs["inputs"]]
292
+
293
+ embedder = HuggingFaceAPISparseDocumentEmbedder(batch_size=1, concurrency_limit=2, progress_bar=False)
294
+ client = MagicMock(spec=httpx.AsyncClient)
295
+ with patch(f"{MODULE}._embed_sparse_async", side_effect=embed_batch):
296
+ embeddings = await embedder._embed_batches_async(client, ["0", "1", "2", "3"])
297
+
298
+ assert maximum_active == 2
299
+ assert [embedding.indices for embedding in embeddings] == [[0], [1], [2], [3]]
300
+
301
+ def test_run_rejects_response_with_wrong_embedding_count(self) -> None:
302
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
303
+
304
+ with (
305
+ patched_client() as (client, _),
306
+ pytest.raises(ValueError, match="Expected one sparse embedding per input"),
307
+ ):
308
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
309
+ embedder.run([Document(content="one"), Document(content="two")])
310
+
311
+ def test_run_propagates_http_error(self) -> None:
312
+ request = httpx.Request("POST", "http://localhost:8080/embed_sparse")
313
+ embedder = HuggingFaceAPISparseDocumentEmbedder(progress_bar=False)
314
+
315
+ with patched_client() as (client, _), pytest.raises(httpx.HTTPStatusError):
316
+ client.post.return_value = httpx.Response(500, request=request)
317
+ embedder.run([Document(content="text")])
318
+
319
+ @pytest.mark.integration
320
+ def test_live_run_tei(self) -> None:
321
+ documents = [Document(content="sparse retrieval"), Document(content="dense retrieval")]
322
+ result = HuggingFaceAPISparseDocumentEmbedder(api_base_url=API_BASE_URL, progress_bar=False).run(documents)
323
+
324
+ documents_with_embeddings = result["documents"]
325
+ assert len(documents_with_embeddings) == len(documents)
326
+ for document in documents_with_embeddings:
327
+ assert isinstance(document.sparse_embedding, SparseEmbedding)
328
+ assert document.sparse_embedding.indices
329
+
330
+ @pytest.mark.integration
331
+ @pytest.mark.asyncio
332
+ async def test_live_run_async_tei(self) -> None:
333
+ documents = [Document(content="sparse retrieval"), Document(content="dense retrieval")]
334
+ result = await HuggingFaceAPISparseDocumentEmbedder(api_base_url=API_BASE_URL, progress_bar=False).run_async(
335
+ documents
336
+ )
337
+
338
+ documents_with_embeddings = result["documents"]
339
+ assert len(documents_with_embeddings) == len(documents)
340
+ for document in documents_with_embeddings:
341
+ assert isinstance(document.sparse_embedding, SparseEmbedding)
342
+ assert document.sparse_embedding.indices
@@ -0,0 +1,263 @@
1
+ # SPDX-FileCopyrightText: 2026-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from typing import Any
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import httpx
11
+ import pytest
12
+ from haystack.dataclasses import SparseEmbedding
13
+ from haystack.utils import Secret
14
+
15
+ from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPISparseTextEmbedder
16
+
17
+ API_BASE_URL = "http://localhost:8080"
18
+ MODULE = "haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder"
19
+
20
+
21
+ def sparse_response(data: Any) -> MagicMock:
22
+ response = MagicMock(spec=httpx.Response)
23
+ response.json.return_value = data
24
+ return response
25
+
26
+
27
+ @contextmanager
28
+ def patched_client(*, is_async: bool = False) -> Iterator[tuple[MagicMock, MagicMock]]:
29
+ """
30
+ Patch the `httpx` client constructor and yield the (client, constructor) mocks.
31
+
32
+ The component builds a client per call, so tests reach the client through the constructor rather than through
33
+ an attribute. `__enter__`/`__aenter__` yield the same mock, so `client.post` assertions read naturally.
34
+ """
35
+ name = "AsyncClient" if is_async else "Client"
36
+ client = MagicMock(spec=getattr(httpx, name))
37
+ if is_async:
38
+ client.__aenter__.return_value = client
39
+ client.__aexit__.return_value = False
40
+ else:
41
+ client.__enter__.return_value = client
42
+ client.__exit__.return_value = False
43
+ with patch(f"{MODULE}.httpx.{name}", return_value=client) as constructor:
44
+ yield client, constructor
45
+
46
+
47
+ class TestHuggingFaceAPISparseTextEmbedder:
48
+ @pytest.mark.parametrize("api_base_url", ["not-a-url", "ftp://localhost/path", "localhost:8080"])
49
+ def test_init_rejects_invalid_api_base_url(self, api_base_url: str) -> None:
50
+ with pytest.raises(ValueError, match="api_base_url must be a valid HTTP URL"):
51
+ HuggingFaceAPISparseTextEmbedder(api_base_url=api_base_url)
52
+
53
+ def test_init_defaults(self) -> None:
54
+ embedder = HuggingFaceAPISparseTextEmbedder()
55
+
56
+ assert embedder.api_base_url == "http://localhost:8080"
57
+ assert embedder.prefix == ""
58
+ assert embedder.suffix == ""
59
+ assert embedder.timeout == 30.0
60
+ assert embedder.headers == {}
61
+
62
+ def test_to_dict_and_from_dict_preserve_env_secret(self, monkeypatch: pytest.MonkeyPatch) -> None:
63
+ monkeypatch.setenv("CUSTOM_HF_TOKEN", "resolved-token")
64
+ embedder = HuggingFaceAPISparseTextEmbedder(
65
+ api_base_url="https://tei.example.test/base/",
66
+ token=Secret.from_env_var("CUSTOM_HF_TOKEN"),
67
+ prefix="query: ",
68
+ suffix="!",
69
+ timeout=None,
70
+ headers={"X-Tenant": "test"},
71
+ )
72
+
73
+ data = embedder.to_dict()
74
+
75
+ assert data == {
76
+ "type": "haystack_integrations.components.embedders.huggingface_api.sparse_text_embedder."
77
+ "HuggingFaceAPISparseTextEmbedder",
78
+ "init_parameters": {
79
+ "api_base_url": "https://tei.example.test/base/",
80
+ "token": {"type": "env_var", "env_vars": ["CUSTOM_HF_TOKEN"], "strict": True},
81
+ "prefix": "query: ",
82
+ "suffix": "!",
83
+ "timeout": None,
84
+ "headers": {"X-Tenant": "test"},
85
+ },
86
+ }
87
+ restored = HuggingFaceAPISparseTextEmbedder.from_dict(data)
88
+ assert restored.api_base_url == embedder.api_base_url
89
+ assert restored.prefix == "query: "
90
+ assert restored.suffix == "!"
91
+ assert restored.timeout is None
92
+ assert restored.headers == {"X-Tenant": "test"}
93
+ assert restored.token is not None
94
+ assert restored.token.resolve_value() == "resolved-token"
95
+
96
+ def test_token_secret_cannot_be_serialized(self) -> None:
97
+ embedder = HuggingFaceAPISparseTextEmbedder(token=Secret.from_token("do-not-serialize"))
98
+
99
+ with pytest.raises(ValueError, match="Cannot serialize token-based secret"):
100
+ embedder.to_dict()
101
+
102
+ @pytest.mark.parametrize("invalid_text", [None, 42, ["text"]])
103
+ def test_run_rejects_non_string_input(self, invalid_text: Any) -> None:
104
+ embedder = HuggingFaceAPISparseTextEmbedder()
105
+
106
+ with pytest.raises(TypeError, match="expects a string"):
107
+ embedder.run(invalid_text)
108
+
109
+ def test_run_posts_tei_request_and_converts_response(self) -> None:
110
+ embedder = HuggingFaceAPISparseTextEmbedder(
111
+ api_base_url="https://tei.example.test/api/",
112
+ token=Secret.from_token("secret"),
113
+ prefix="query: ",
114
+ suffix=" </s>",
115
+ timeout=4.5,
116
+ headers={"X-Tenant": "one"},
117
+ )
118
+
119
+ with patched_client() as (client, constructor):
120
+ response = sparse_response([[{"index": 12, "value": 1}, {"index": 99, "value": 0.25}]])
121
+ client.post.return_value = response
122
+ result = embedder.run("cheese")
123
+
124
+ constructor.assert_called_once_with(
125
+ base_url="https://tei.example.test/api/",
126
+ timeout=4.5,
127
+ headers={"Authorization": "Bearer secret", "X-Tenant": "one"},
128
+ )
129
+ client.post.assert_called_once_with("embed_sparse", json={"inputs": "query: cheese </s>"})
130
+ response.raise_for_status.assert_called_once_with()
131
+ assert result == {"sparse_embedding": SparseEmbedding(indices=[12, 99], values=[1.0, 0.25])}
132
+
133
+ def test_token_is_sent_as_bearer_authorization(self, monkeypatch: pytest.MonkeyPatch) -> None:
134
+ monkeypatch.delenv("HF_API_TOKEN", raising=False)
135
+ monkeypatch.setenv("HF_TOKEN", "env-token")
136
+ embedder = HuggingFaceAPISparseTextEmbedder()
137
+
138
+ with patched_client() as (client, constructor):
139
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
140
+ embedder.run("text")
141
+
142
+ assert constructor.call_args.kwargs["headers"] == {"Authorization": "Bearer env-token"}
143
+
144
+ def test_explicit_authorization_header_wins_over_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
145
+ """An explicit header must not be replaced by a token that only happens to be set in the environment."""
146
+ monkeypatch.delenv("HF_API_TOKEN", raising=False)
147
+ monkeypatch.setenv("HF_TOKEN", "env-token")
148
+ embedder = HuggingFaceAPISparseTextEmbedder(headers={"Authorization": "Basic test-key"})
149
+
150
+ with patched_client() as (client, constructor):
151
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
152
+ embedder.run("text")
153
+
154
+ assert constructor.call_args.kwargs["headers"] == {"Authorization": "Basic test-key"}
155
+
156
+ def test_token_is_resolved_per_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
157
+ """The token is read at request time, so a rotated environment variable is picked up."""
158
+ monkeypatch.delenv("HF_API_TOKEN", raising=False)
159
+ monkeypatch.setenv("HF_TOKEN", "first-token")
160
+ embedder = HuggingFaceAPISparseTextEmbedder()
161
+
162
+ with patched_client() as (client, constructor):
163
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
164
+ embedder.run("text")
165
+ monkeypatch.setenv("HF_TOKEN", "second-token")
166
+ embedder.run("text")
167
+
168
+ assert [each.kwargs["headers"]["Authorization"] for each in constructor.call_args_list] == [
169
+ "Bearer first-token",
170
+ "Bearer second-token",
171
+ ]
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_run_async_uses_async_client_and_request_options(self) -> None:
175
+ embedder = HuggingFaceAPISparseTextEmbedder(
176
+ api_base_url="http://tei:8080/", token=Secret.from_token("token"), timeout=9, headers={"X-Test": "yes"}
177
+ )
178
+
179
+ with patched_client(is_async=True) as (client, constructor):
180
+ response = sparse_response([[{"index": 7, "value": 2.5}]])
181
+ client.post.return_value = response
182
+ result = await embedder.run_async("input")
183
+
184
+ constructor.assert_called_once_with(
185
+ base_url="http://tei:8080/", timeout=9, headers={"Authorization": "Bearer token", "X-Test": "yes"}
186
+ )
187
+ client.post.assert_awaited_once_with("embed_sparse", json={"inputs": "input"})
188
+ response.raise_for_status.assert_called_once_with()
189
+ assert result["sparse_embedding"] == SparseEmbedding(indices=[7], values=[2.5])
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_run_async_builds_and_closes_a_client_per_call(self) -> None:
193
+ """
194
+ The component must not hold on to an `httpx.AsyncClient`.
195
+
196
+ A cached client binds its keep-alive connection pool to the first event loop that used it, so a component
197
+ reused under a second `asyncio.run` would fail with `RuntimeError: Event loop is closed`.
198
+ """
199
+ embedder = HuggingFaceAPISparseTextEmbedder()
200
+
201
+ with patched_client(is_async=True) as (client, constructor):
202
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
203
+ await embedder.run_async("one")
204
+ await embedder.run_async("two")
205
+
206
+ assert constructor.call_count == 2
207
+ assert client.__aexit__.await_count == 2
208
+ assert not hasattr(embedder, "_async_client")
209
+
210
+ def test_run_builds_and_closes_a_client_per_call(self) -> None:
211
+ embedder = HuggingFaceAPISparseTextEmbedder()
212
+
213
+ with patched_client() as (client, constructor):
214
+ client.post.return_value = sparse_response([[{"index": 1, "value": 1}]])
215
+ embedder.run("one")
216
+ embedder.run("two")
217
+
218
+ assert constructor.call_count == 2
219
+ assert client.__exit__.call_count == 2
220
+ assert not hasattr(embedder, "_client")
221
+
222
+ @pytest.mark.parametrize(
223
+ "payload",
224
+ [
225
+ {"index": 1, "value": 0.5},
226
+ [],
227
+ ["not-a-list"],
228
+ [[{"index": 1}]],
229
+ [[{"index": "1", "value": 0.5}]],
230
+ [[{"index": 1, "value": True}]],
231
+ ],
232
+ )
233
+ def test_run_rejects_malformed_tei_responses(self, payload: Any) -> None:
234
+ embedder = HuggingFaceAPISparseTextEmbedder()
235
+
236
+ with patched_client() as (client, _), pytest.raises(ValueError):
237
+ client.post.return_value = sparse_response(payload)
238
+ embedder.run("text")
239
+
240
+ def test_run_propagates_http_error(self) -> None:
241
+ request = httpx.Request("POST", "http://localhost:8080/embed_sparse")
242
+ embedder = HuggingFaceAPISparseTextEmbedder()
243
+
244
+ with patched_client() as (client, _), pytest.raises(httpx.HTTPStatusError) as exc_info:
245
+ client.post.return_value = httpx.Response(503, request=request)
246
+ embedder.run("text")
247
+
248
+ assert exc_info.value.response.status_code == 503
249
+
250
+ @pytest.mark.integration
251
+ def test_live_run_tei(self) -> None:
252
+ result = HuggingFaceAPISparseTextEmbedder(api_base_url=API_BASE_URL).run("sparse retrieval")
253
+
254
+ assert isinstance(result["sparse_embedding"], SparseEmbedding)
255
+ assert result["sparse_embedding"].indices
256
+
257
+ @pytest.mark.integration
258
+ @pytest.mark.asyncio
259
+ async def test_live_run_async_tei(self) -> None:
260
+ result = await HuggingFaceAPISparseTextEmbedder(api_base_url=API_BASE_URL).run_async("sparse retrieval")
261
+
262
+ assert isinstance(result["sparse_embedding"], SparseEmbedding)
263
+ assert result["sparse_embedding"].indices
@@ -1,7 +0,0 @@
1
- # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
- from .document_embedder import HuggingFaceAPIDocumentEmbedder
5
- from .text_embedder import HuggingFaceAPITextEmbedder
6
-
7
- __all__ = ["HuggingFaceAPIDocumentEmbedder", "HuggingFaceAPITextEmbedder"]