nace-haystack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- haystack_integrations/common/nace/__init__.py +3 -0
- haystack_integrations/common/nace/client.py +171 -0
- haystack_integrations/common/nace/concurrency.py +49 -0
- haystack_integrations/common/nace/errors.py +138 -0
- haystack_integrations/common/nace/images.py +204 -0
- haystack_integrations/common/nace/request.py +144 -0
- haystack_integrations/common/py.typed +0 -0
- haystack_integrations/components/classifiers/nace/__init__.py +7 -0
- haystack_integrations/components/classifiers/nace/document_classifier.py +390 -0
- haystack_integrations/components/classifiers/py.typed +0 -0
- haystack_integrations/components/evaluators/nace/__init__.py +13 -0
- haystack_integrations/components/evaluators/nace/_base.py +247 -0
- haystack_integrations/components/evaluators/nace/context_relevance.py +139 -0
- haystack_integrations/components/evaluators/nace/faithfulness.py +169 -0
- haystack_integrations/components/evaluators/py.typed +0 -0
- haystack_integrations/components/routers/nace/__init__.py +7 -0
- haystack_integrations/components/routers/nace/text_router.py +316 -0
- haystack_integrations/components/routers/py.typed +0 -0
- nace_haystack-0.1.0.dist-info/METADATA +230 -0
- nace_haystack-0.1.0.dist-info/RECORD +22 -0
- nace_haystack-0.1.0.dist-info/WHEEL +4 -0
- nace_haystack-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Nace AI
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import nace_sdk
|
|
12
|
+
from haystack import logging
|
|
13
|
+
from haystack.utils import Secret
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("nace-haystack")
|
|
19
|
+
except PackageNotFoundError: # a source checkout that isn't installed
|
|
20
|
+
__version__ = "0.0.0"
|
|
21
|
+
|
|
22
|
+
CLIENT_NAME = f"nace-haystack/{__version__}"
|
|
23
|
+
|
|
24
|
+
# The newest Drex model, used when a component is created without `model`. Bumped in a package release whenever
|
|
25
|
+
# Drex ships a new model (see RELEASING.md); every other model id is sent as given and Drex decides.
|
|
26
|
+
DEFAULT_MODEL = "drex-max-v1.1"
|
|
27
|
+
|
|
28
|
+
# Released, pinned versions never gain image input, so this list can't go stale.
|
|
29
|
+
TEXT_ONLY_MODELS = frozenset({"drex-v1.5", "drex-v2.0"})
|
|
30
|
+
|
|
31
|
+
# Drex may take up to 55 seconds to answer, so shorter timeouts can cut off a valid request.
|
|
32
|
+
MIN_TIMEOUT = 60.0
|
|
33
|
+
|
|
34
|
+
# The longest wait between retries, in seconds, even when the server asks for a longer one
|
|
35
|
+
MAX_RETRY_WAIT = 60.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CappedRetryPolicy(nace_sdk.RetryPolicy):
|
|
39
|
+
"""
|
|
40
|
+
nace-sdk's retry policy, with every wait capped at `MAX_RETRY_WAIT`.
|
|
41
|
+
|
|
42
|
+
The SDK honours `retry-after-ms` however long it is, so a misbehaving server or gateway could stall a pipeline
|
|
43
|
+
for hours. This overrides the SDK's private `_wait` until the SDK caps waits itself.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def _wait(self, attempt: int, error: BaseException) -> float:
|
|
47
|
+
return min(super()._wait(attempt, error), MAX_RETRY_WAIT)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_text_only(model: str) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Whether `model` is a released Drex version known to read text only.
|
|
53
|
+
|
|
54
|
+
:param model: A Drex model id, optionally with a provider prefix such as `nace-ai/` and an OpenRouter variant
|
|
55
|
+
suffix such as `:floor`.
|
|
56
|
+
:returns: `True` for `drex-v1.5` and `drex-v2.0`, `False` for every other id.
|
|
57
|
+
"""
|
|
58
|
+
return model.rsplit("/", 1)[-1].lstrip("~").partition(":")[0] in TEXT_ONLY_MODELS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_openrouter(base_url: str | None) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Whether `base_url` points to OpenRouter, which takes a different request format.
|
|
64
|
+
|
|
65
|
+
:param base_url: The API base URL, or `None` for the default.
|
|
66
|
+
:returns: `True` if the host is openrouter.ai.
|
|
67
|
+
"""
|
|
68
|
+
return bool(base_url) and (urlsplit(str(base_url)).hostname or "").endswith("openrouter.ai")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def provider_name(base_url: str | None) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Names who answers requests sent to `base_url`, for error messages.
|
|
74
|
+
|
|
75
|
+
:param base_url: The API base URL, or `None` for the default.
|
|
76
|
+
:returns: `"OpenRouter"` or `"Drex"`.
|
|
77
|
+
"""
|
|
78
|
+
return "OpenRouter" if is_openrouter(base_url) else "Drex"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_base_url(api_base_url: str | None) -> str | None:
|
|
82
|
+
"""
|
|
83
|
+
Resolves the API base URL from the argument or the `NACE_BASE_URL` environment variable.
|
|
84
|
+
|
|
85
|
+
:param api_base_url: The base URL passed to the component, or `None`.
|
|
86
|
+
:returns: The base URL, or `None` for the SDK default. A blank value (common in `.env` files and CI) means the
|
|
87
|
+
default. OpenRouter's documented `.../api/v1` is trimmed to `.../api`, because the SDK appends `/v1/...`.
|
|
88
|
+
"""
|
|
89
|
+
base_url = api_base_url if api_base_url is not None else os.environ.get("NACE_BASE_URL")
|
|
90
|
+
base_url = (base_url or "").strip() or None
|
|
91
|
+
if base_url and is_openrouter(base_url):
|
|
92
|
+
base_url = base_url.rstrip("/").removesuffix("/v1")
|
|
93
|
+
return base_url
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def client_options(
|
|
97
|
+
api_key: Secret, api_base_url: str | None, timeout: float | None, max_retries: int | None
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""
|
|
100
|
+
Builds the keyword arguments for `nace_sdk.NaceClient` and `nace_sdk.AsyncNaceClient`.
|
|
101
|
+
|
|
102
|
+
:param api_key: The Nace API key.
|
|
103
|
+
:param api_base_url: The base URL passed to the component, or `None`.
|
|
104
|
+
:param timeout: Timeout in seconds for each request attempt. `None` means 60 seconds.
|
|
105
|
+
:param max_retries: Maximum number of retries after a failed attempt. `None` means the SDK default of 2.
|
|
106
|
+
:returns: The client keyword arguments.
|
|
107
|
+
"""
|
|
108
|
+
base_url = resolve_base_url(api_base_url)
|
|
109
|
+
if timeout is None:
|
|
110
|
+
timeout = MIN_TIMEOUT
|
|
111
|
+
elif timeout < MIN_TIMEOUT:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"A timeout of {timeout} s can cut off Drex, which may take up to 55 s to answer. Use at least {minimum} s.",
|
|
114
|
+
timeout=timeout,
|
|
115
|
+
minimum=MIN_TIMEOUT,
|
|
116
|
+
)
|
|
117
|
+
headers = {}
|
|
118
|
+
if is_openrouter(base_url):
|
|
119
|
+
headers = {"HTTP-Referer": "https://nace.ai", "X-OpenRouter-Title": "Drex for Haystack"}
|
|
120
|
+
# The SDK's retry budget defaults to 30 seconds in total, which would skip retries after a long attempt.
|
|
121
|
+
# `timeout` already bounds each attempt, so the budget is disabled.
|
|
122
|
+
retry_kwargs: dict[str, Any] = {"timeout": None}
|
|
123
|
+
if max_retries is not None:
|
|
124
|
+
retry_kwargs["max_retries"] = max_retries
|
|
125
|
+
return {
|
|
126
|
+
"api_key": api_key.resolve_value(),
|
|
127
|
+
"base_url": base_url,
|
|
128
|
+
"timeout": timeout,
|
|
129
|
+
"retry": CappedRetryPolicy(**retry_kwargs),
|
|
130
|
+
"headers": headers,
|
|
131
|
+
"client_info": CLIENT_NAME,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _set_user_agent(request: httpx.Request) -> None:
|
|
136
|
+
# The SDK sends its own User-Agent; ours names this integration so traffic can be measured per channel.
|
|
137
|
+
request.headers["User-Agent"] = CLIENT_NAME
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _set_user_agent_async(request: httpx.Request) -> None:
|
|
141
|
+
_set_user_agent(request)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def make_client(options: dict[str, Any]) -> nace_sdk.NaceClient:
|
|
145
|
+
"""
|
|
146
|
+
Creates a synchronous Nace client that identifies this integration on every request.
|
|
147
|
+
|
|
148
|
+
:param options: Keyword arguments from `client_options`, plus an optional `transport` for tests.
|
|
149
|
+
:returns: The client.
|
|
150
|
+
"""
|
|
151
|
+
options = dict(options)
|
|
152
|
+
http_client = httpx.Client(
|
|
153
|
+
timeout=options["timeout"], transport=options.pop("transport", None), event_hooks={"request": [_set_user_agent]}
|
|
154
|
+
)
|
|
155
|
+
return nace_sdk.NaceClient(**options, http_client=http_client)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def make_async_client(options: dict[str, Any]) -> nace_sdk.AsyncNaceClient:
|
|
159
|
+
"""
|
|
160
|
+
Creates an asynchronous Nace client that identifies this integration on every request.
|
|
161
|
+
|
|
162
|
+
:param options: Keyword arguments from `client_options`, plus an optional `transport` for tests.
|
|
163
|
+
:returns: The client.
|
|
164
|
+
"""
|
|
165
|
+
options = dict(options)
|
|
166
|
+
http_client = httpx.AsyncClient(
|
|
167
|
+
timeout=options["timeout"],
|
|
168
|
+
transport=options.pop("transport", None),
|
|
169
|
+
event_hooks={"request": [_set_user_agent_async]},
|
|
170
|
+
)
|
|
171
|
+
return nace_sdk.AsyncNaceClient(**options, http_client=http_client)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Nace AI
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
6
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
R = TypeVar("R")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def map_in_threads(func: Callable[[T], R], items: Sequence[T], max_workers: int) -> list[R]:
|
|
14
|
+
"""
|
|
15
|
+
Applies `func` to every item on up to `max_workers` threads and returns the results in input order.
|
|
16
|
+
|
|
17
|
+
Unlike `Executor.map`, which notices a failure only once every earlier item is done, the first exception from
|
|
18
|
+
any item stops the run: no item starts after it, so no further requests are sent.
|
|
19
|
+
|
|
20
|
+
:param func: The function to apply.
|
|
21
|
+
:param items: The inputs.
|
|
22
|
+
:param max_workers: The most items processed at once.
|
|
23
|
+
:returns: `func(item)` for each item, in the order of `items`.
|
|
24
|
+
:raises Exception: The first exception `func` raised, after the items already running have finished.
|
|
25
|
+
"""
|
|
26
|
+
results: dict[int, R] = {}
|
|
27
|
+
pending: dict[Future[R], int] = {}
|
|
28
|
+
queue: Iterator[tuple[int, T]] = iter(enumerate(items))
|
|
29
|
+
# Items are handed out one at a time as workers free up, rather than queued all at once, so a worker can never
|
|
30
|
+
# pick up another item between a failure and the moment it is noticed
|
|
31
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
32
|
+
|
|
33
|
+
def start_next() -> None:
|
|
34
|
+
for index, item in queue:
|
|
35
|
+
pending[executor.submit(func, item)] = index
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
for _ in range(max_workers):
|
|
39
|
+
start_next()
|
|
40
|
+
while pending:
|
|
41
|
+
done, _ = wait(pending, return_when=FIRST_COMPLETED)
|
|
42
|
+
for future in done:
|
|
43
|
+
index = pending.pop(future)
|
|
44
|
+
error = future.exception()
|
|
45
|
+
if error is not None:
|
|
46
|
+
raise error
|
|
47
|
+
results[index] = future.result()
|
|
48
|
+
start_next()
|
|
49
|
+
return [results[index] for index in range(len(items))]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Nace AI
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import nace_sdk
|
|
11
|
+
|
|
12
|
+
BILLING_URL = "https://console.nace.ai/billing"
|
|
13
|
+
OPENROUTER_CREDITS_URL = "https://openrouter.ai/settings/credits"
|
|
14
|
+
|
|
15
|
+
# Longer messages are cut, so an unexpected response body can't flood logs and tracebacks
|
|
16
|
+
MAX_MESSAGE_CHARS = 500
|
|
17
|
+
|
|
18
|
+
_IMAGE_PATH = re.compile(r"^images\.(\d+)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def readable_error(error: nace_sdk.NaceError, provider: str, *, image_types: Sequence[str] = ()) -> nace_sdk.NaceError:
|
|
22
|
+
"""
|
|
23
|
+
Rewrites an API error so its message says who refused the request and what to change.
|
|
24
|
+
|
|
25
|
+
The result is an instance of the same nace-sdk class, so `except nace_sdk.NaceError` (or a narrower class such
|
|
26
|
+
as `nace_sdk.NaceRateLimitError`) still catches it, and its `status`, `body`, `issues`, `request_id` and
|
|
27
|
+
`idempotency_key` are kept. A 404 or 405 names the URL, which is how a wrong base URL shows.
|
|
28
|
+
|
|
29
|
+
:param error: The error nace-sdk raised.
|
|
30
|
+
:param provider: Who answered: `"Drex"`, or `"OpenRouter"` on that backend.
|
|
31
|
+
:param image_types: The MIME type of each image sent, in order, so a refused PDF can be named.
|
|
32
|
+
:returns: The readable error, or `error` itself if it isn't an HTTP error response.
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(error, nace_sdk.NaceAPIError) or isinstance(error, nace_sdk.NaceAPIResponseValidationError):
|
|
35
|
+
return error
|
|
36
|
+
message, found = _unwrap(error.message) if error.message else (None, [])
|
|
37
|
+
if message:
|
|
38
|
+
message = _shorten(message, provider, error.status)
|
|
39
|
+
issues = error.issues or found
|
|
40
|
+
# nace-sdk prints errors as "<status> <message> (request_id=...)"
|
|
41
|
+
readable = type(error)(
|
|
42
|
+
error.status,
|
|
43
|
+
error.body,
|
|
44
|
+
error.headers,
|
|
45
|
+
message=f"from {provider}: {_describe(error, provider, message, issues, image_types)}",
|
|
46
|
+
)
|
|
47
|
+
readable.idempotency_key = error.idempotency_key
|
|
48
|
+
return readable
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _unwrap(message: str) -> tuple[str, list[dict[str, str]]]:
|
|
52
|
+
# Gateways such as OpenRouter pass upstream errors through as text like `HTTP 422: {"detail": "..."}` or a JSON
|
|
53
|
+
# list of schema issues. Return a short message and any `{"path", "message"}` issues found in it.
|
|
54
|
+
text = message.strip()
|
|
55
|
+
wrapped = re.match(r"^HTTP \d{3}: (.*)$", text, re.DOTALL)
|
|
56
|
+
if wrapped:
|
|
57
|
+
text = wrapped.group(1).strip()
|
|
58
|
+
try:
|
|
59
|
+
data: Any = json.loads(text)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return text, []
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
inner = data.get("detail") or data.get("message") or data.get("error")
|
|
64
|
+
if isinstance(inner, str):
|
|
65
|
+
return inner, []
|
|
66
|
+
if isinstance(inner, dict) and isinstance(inner.get("message"), str):
|
|
67
|
+
return inner["message"], []
|
|
68
|
+
data = inner
|
|
69
|
+
if isinstance(data, list):
|
|
70
|
+
issues = []
|
|
71
|
+
for item in data:
|
|
72
|
+
if isinstance(item, dict) and item.get("message"):
|
|
73
|
+
path = item.get("path", "")
|
|
74
|
+
if isinstance(path, list):
|
|
75
|
+
path = ".".join(str(part) for part in path)
|
|
76
|
+
issues.append({"path": str(path), "message": str(item["message"])})
|
|
77
|
+
if issues:
|
|
78
|
+
return "the request is invalid", issues
|
|
79
|
+
return text, []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _shorten(message: str, provider: str, status: int) -> str:
|
|
83
|
+
# Gateways and proxies answer some failures with an HTML page, which says nothing useful in a Python error
|
|
84
|
+
if message.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
|
|
85
|
+
return f"{provider} returned an HTML error page (HTTP {status})"
|
|
86
|
+
if len(message) > MAX_MESSAGE_CHARS:
|
|
87
|
+
return message[:MAX_MESSAGE_CHARS] + "…"
|
|
88
|
+
return message
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _describe(
|
|
92
|
+
error: nace_sdk.NaceAPIError,
|
|
93
|
+
provider: str,
|
|
94
|
+
message: str | None,
|
|
95
|
+
issues: list[dict[str, str]],
|
|
96
|
+
image_types: Sequence[str],
|
|
97
|
+
) -> str:
|
|
98
|
+
if error.status == 413: # noqa: PLR2004
|
|
99
|
+
# Often an HTML page from the gateway rather than a Drex error
|
|
100
|
+
if not image_types:
|
|
101
|
+
return "the request is too large. Shorten the text."
|
|
102
|
+
text = (
|
|
103
|
+
"the request is too large. Send fewer or smaller images, or pass big images as files uploaded to Drex "
|
|
104
|
+
"with nace-sdk."
|
|
105
|
+
)
|
|
106
|
+
if "application/pdf" in image_types:
|
|
107
|
+
text += (
|
|
108
|
+
"\nA PDF can't be shrunk to fit: convert its pages to images first, for example with Haystack's "
|
|
109
|
+
"PDFToImageContent."
|
|
110
|
+
)
|
|
111
|
+
return text
|
|
112
|
+
text = message or "the request failed"
|
|
113
|
+
lines = [f"{issue.get('path') or '(request)'}: {issue['message']}" for issue in issues if issue.get("message")]
|
|
114
|
+
# Drex's top-level message often repeats a single issue; don't print it twice
|
|
115
|
+
repeated = len(issues) == 1 and (issues[0].get("message") == message or lines == [message])
|
|
116
|
+
if lines and not repeated:
|
|
117
|
+
text += "\n" + "\n".join(f" {line}" for line in lines)
|
|
118
|
+
for issue in issues:
|
|
119
|
+
refused = _IMAGE_PATH.match(issue.get("path") or "")
|
|
120
|
+
if (
|
|
121
|
+
refused
|
|
122
|
+
and int(refused.group(1)) < len(image_types)
|
|
123
|
+
and image_types[int(refused.group(1))] == "application/pdf"
|
|
124
|
+
):
|
|
125
|
+
number = int(refused.group(1)) + 1
|
|
126
|
+
text += (
|
|
127
|
+
f"\nImage {number} is a PDF, and Drex reads images. Convert PDF pages to images first, for example "
|
|
128
|
+
"with Haystack's PDFToImageContent."
|
|
129
|
+
)
|
|
130
|
+
if error.status in (404, 405) and error.endpoint:
|
|
131
|
+
# Usually a wrong base URL (`api_base_url` or `NACE_BASE_URL`)
|
|
132
|
+
text += f"\n{error.endpoint} doesn't exist. Check the base URL."
|
|
133
|
+
if error.status == 402: # noqa: PLR2004
|
|
134
|
+
if provider == "OpenRouter":
|
|
135
|
+
text += f"\nAdd credits at {OPENROUTER_CREDITS_URL}"
|
|
136
|
+
else:
|
|
137
|
+
text += f"\nAdd funds at {BILLING_URL}"
|
|
138
|
+
return text
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Nace AI
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any, TypeAlias
|
|
10
|
+
|
|
11
|
+
import filetype
|
|
12
|
+
import nace_sdk
|
|
13
|
+
from haystack import logging
|
|
14
|
+
from haystack.dataclasses import ByteStream, ImageContent
|
|
15
|
+
from haystack.lazy_imports import LazyImport
|
|
16
|
+
|
|
17
|
+
with LazyImport("Run 'pip install pillow'") as pillow_import:
|
|
18
|
+
from PIL import Image, ImageOps
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
ImageInput: TypeAlias = (
|
|
23
|
+
ImageContent | ByteStream | nace_sdk.UploadedFile | nace_sdk.WorkspaceFileSource | Mapping[str, str]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Drex's documented request size limit. It is used only to decide when inline images must be shrunk; Drex enforces
|
|
27
|
+
# the real limit and explains it when a request is too large.
|
|
28
|
+
MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
_JPEG_QUALITY = 90
|
|
31
|
+
_MAX_SHRINK_ROUNDS = 6
|
|
32
|
+
# Below about 1 megapixel, small print on a scanned page stops being readable
|
|
33
|
+
_MIN_SHRUNK_PIXELS = 1_000_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sniff(data: bytes) -> str:
|
|
37
|
+
guess = filetype.guess(data)
|
|
38
|
+
return guess.mime if guess else "application/octet-stream"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _declared(mime_type: str | None) -> str | None:
|
|
42
|
+
# `application/octet-stream` says only "some bytes", which is what loaders such as a plain file read produce
|
|
43
|
+
return None if mime_type == "application/octet-stream" else mime_type
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reference(image: Any) -> dict[str, str] | None:
|
|
47
|
+
if isinstance(image, (nace_sdk.UploadedFile, nace_sdk.WorkspaceFileSource)):
|
|
48
|
+
return {"type": "workspace_file", "workspace_id": str(image.workspace_id), "file_id": str(image.file_id)}
|
|
49
|
+
if isinstance(image, Mapping) and image.get("type") == "workspace_file":
|
|
50
|
+
return {"type": "workspace_file", "workspace_id": str(image["workspace_id"]), "file_id": str(image["file_id"])}
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_wire_image(image: ImageInput, *, openrouter: bool) -> str | dict[str, str]:
|
|
55
|
+
"""
|
|
56
|
+
Converts an image to the form Drex reads.
|
|
57
|
+
|
|
58
|
+
Images are sent with their own MIME type; Drex decides which types it reads and explains a refusal.
|
|
59
|
+
|
|
60
|
+
:param image: An `ImageContent`, a `ByteStream`, or a reference to a file uploaded to Drex with nace-sdk.
|
|
61
|
+
:param openrouter: Whether the request goes to OpenRouter, which takes inline images only.
|
|
62
|
+
:returns: A `data:` URL, or a `workspace_file` reference.
|
|
63
|
+
:raises ValueError: If the image is a file reference on OpenRouter, or isn't a supported input.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(image, ImageContent):
|
|
66
|
+
mime_type = _declared(image.mime_type) or _sniff(base64.b64decode(image.base64_image))
|
|
67
|
+
return f"data:{mime_type};base64,{image.base64_image}"
|
|
68
|
+
if isinstance(image, ByteStream):
|
|
69
|
+
mime_type = _declared(image.mime_type) or _sniff(image.data)
|
|
70
|
+
return f"data:{mime_type};base64,{base64.b64encode(image.data).decode()}"
|
|
71
|
+
reference = _reference(image)
|
|
72
|
+
if reference is None:
|
|
73
|
+
msg = (
|
|
74
|
+
f"Unsupported image {type(image).__name__}. Pass an ImageContent, a ByteStream, or a file uploaded to "
|
|
75
|
+
"Drex with nace-sdk (an UploadedFile, a WorkspaceFileSource, or a "
|
|
76
|
+
'{"type": "workspace_file", "workspace_id": ..., "file_id": ...} dict).'
|
|
77
|
+
)
|
|
78
|
+
raise ValueError(msg)
|
|
79
|
+
if openrouter:
|
|
80
|
+
msg = "OpenRouter accepts inline images only, not files uploaded to Drex. Pass the image itself instead."
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
return reference
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def image_types(images: Sequence[ImageInput]) -> list[str]:
|
|
86
|
+
"""
|
|
87
|
+
Lists the MIME type of each image, so error messages can name the one Drex refused.
|
|
88
|
+
|
|
89
|
+
:param images: The images as the caller passed them.
|
|
90
|
+
:returns: One MIME type per image, or `"workspace_file"` for a reference to an uploaded file.
|
|
91
|
+
"""
|
|
92
|
+
types = []
|
|
93
|
+
for image in images:
|
|
94
|
+
if isinstance(image, ImageContent):
|
|
95
|
+
types.append(_declared(image.mime_type) or _sniff(base64.b64decode(image.base64_image)))
|
|
96
|
+
elif isinstance(image, ByteStream):
|
|
97
|
+
types.append(_declared(image.mime_type) or _sniff(image.data))
|
|
98
|
+
else:
|
|
99
|
+
types.append("workspace_file")
|
|
100
|
+
return types
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fit_images(images: list[str | dict[str, str]], budget: int) -> list[str | dict[str, str]]:
|
|
104
|
+
"""
|
|
105
|
+
Shrinks inline images that don't fit in one request, keeping everything else as it is.
|
|
106
|
+
|
|
107
|
+
Drex Max downsizes every image to about 1.6 megapixels itself, so a shrunk image that still fits keeps the
|
|
108
|
+
detail the model reads. Images that fit are sent untouched, so a model that reads more detail isn't held back.
|
|
109
|
+
An image is never shrunk below about 1 megapixel, where small print stops being readable: when the text leaves
|
|
110
|
+
too little room, a clear error says so instead.
|
|
111
|
+
|
|
112
|
+
:param images: `data:` URLs and `workspace_file` references, in request order.
|
|
113
|
+
:param budget: How many characters the inline images may take in the request in total.
|
|
114
|
+
:returns: The images in the same order. Data URLs over their share of `budget` are re-encoded as JPEG and scaled
|
|
115
|
+
down until they fit. References, and data Pillow can't open (such as a PDF), are returned unchanged.
|
|
116
|
+
:raises ValueError: If an image must be shrunk and Pillow isn't installed, or can't fit without going below
|
|
117
|
+
the quality floor.
|
|
118
|
+
"""
|
|
119
|
+
inline = [index for index, image in enumerate(images) if isinstance(image, str)]
|
|
120
|
+
if not inline or sum(len(images[index]) for index in inline) <= budget:
|
|
121
|
+
return list(images)
|
|
122
|
+
share = budget // len(inline)
|
|
123
|
+
oversized = [index for index in inline if len(images[index]) > share]
|
|
124
|
+
left = budget - sum(len(images[index]) for index in inline if index not in oversized)
|
|
125
|
+
share = left // len(oversized)
|
|
126
|
+
fitted = list(images)
|
|
127
|
+
for index in oversized:
|
|
128
|
+
fitted[index] = _shrink(str(images[index]), share, number=index + 1)
|
|
129
|
+
return fitted
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _no_room(number: int) -> ValueError:
|
|
133
|
+
msg = (
|
|
134
|
+
f"Image {number} is too big to send with this much text: it would have to shrink below about 1 megapixel to "
|
|
135
|
+
"fit Drex's request limit. Shorten the text, send fewer images, or pass big images as files uploaded to Drex "
|
|
136
|
+
"with nace-sdk, which don't count toward the request size."
|
|
137
|
+
)
|
|
138
|
+
return ValueError(msg)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _shrink(data_url: str, share: int, *, number: int) -> str:
|
|
142
|
+
try:
|
|
143
|
+
pillow_import.check()
|
|
144
|
+
except ImportError as error:
|
|
145
|
+
msg = (
|
|
146
|
+
"An image is too big to send to Drex in one request, and shrinking it needs Pillow. "
|
|
147
|
+
"Run 'pip install pillow', or downscale images before this component, for example with "
|
|
148
|
+
"ImageFileToImageContent(size=(2048, 2048)) or PDFToImageContent(size=(2048, 2048))."
|
|
149
|
+
)
|
|
150
|
+
raise ValueError(msg) from error
|
|
151
|
+
|
|
152
|
+
raw = base64.b64decode(data_url.partition(",")[2])
|
|
153
|
+
try:
|
|
154
|
+
with Image.open(io.BytesIO(raw)) as opened:
|
|
155
|
+
# Phone photos store their rotation in EXIF, which JPEG re-encoding drops: apply it to the pixels first
|
|
156
|
+
original = _flatten(ImageOps.exif_transpose(opened))
|
|
157
|
+
except OSError:
|
|
158
|
+
# Not an image Pillow can read, such as a PDF. Drex refuses it with a readable error.
|
|
159
|
+
return data_url
|
|
160
|
+
if share <= 0:
|
|
161
|
+
raise _no_room(number)
|
|
162
|
+
|
|
163
|
+
scale = 1.0
|
|
164
|
+
image = original
|
|
165
|
+
shrunk = _jpeg(image)
|
|
166
|
+
for _ in range(_MAX_SHRINK_ROUNDS):
|
|
167
|
+
if len(shrunk) <= share:
|
|
168
|
+
break
|
|
169
|
+
scale *= math.sqrt(share / len(shrunk)) * 0.95
|
|
170
|
+
size = (max(1, round(original.width * scale)), max(1, round(original.height * scale)))
|
|
171
|
+
if size[0] * size[1] < _MIN_SHRUNK_PIXELS:
|
|
172
|
+
raise _no_room(number)
|
|
173
|
+
image = original.resize(size, Image.Resampling.LANCZOS)
|
|
174
|
+
shrunk = _jpeg(image)
|
|
175
|
+
if len(shrunk) > share:
|
|
176
|
+
raise _no_room(number)
|
|
177
|
+
logger.warning(
|
|
178
|
+
"Image {original} ({before:.1f} MB) -> {size} ({after:.1f} MB), shrunk to fit Drex's request limit.",
|
|
179
|
+
original=f"{original.width}x{original.height}",
|
|
180
|
+
before=len(raw) / 1e6,
|
|
181
|
+
size=f"{image.width}x{image.height}",
|
|
182
|
+
after=len(shrunk) * 3 / 4 / 1e6,
|
|
183
|
+
)
|
|
184
|
+
return shrunk
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _flatten(image: "Image.Image") -> "Image.Image":
|
|
188
|
+
# JPEG has no transparency: paint transparent areas white rather than letting them turn black.
|
|
189
|
+
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
|
190
|
+
rgba = image.convert("RGBA")
|
|
191
|
+
page = Image.new("RGB", rgba.size, (255, 255, 255))
|
|
192
|
+
page.paste(rgba, mask=rgba.getchannel("A"))
|
|
193
|
+
return page
|
|
194
|
+
# 16-bit and 32-bit grayscale (common in scanner output) would be clipped at 255, turning the page white.
|
|
195
|
+
# Scale it down to 8 bits instead.
|
|
196
|
+
if image.mode.startswith("I;16") or image.mode == "I":
|
|
197
|
+
image = image.convert("I").point(lambda value: value / 256).convert("L")
|
|
198
|
+
return image.convert("RGB")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _jpeg(image: "Image.Image") -> str:
|
|
202
|
+
buffer = io.BytesIO()
|
|
203
|
+
image.save(buffer, "JPEG", quality=_JPEG_QUALITY)
|
|
204
|
+
return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode()
|