langchain-nace 0.1.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.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ .coverage
6
+ coverage.xml
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nace AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-nace
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for Drex decision models by Nace
5
+ Project-URL: Homepage, https://console.nace.ai/docs
6
+ Project-URL: Repository, https://github.com/nace-ai/langchain-nace
7
+ Project-URL: Issues, https://github.com/nace-ai/langchain-nace/issues
8
+ Author-email: Nace AI <hello@nace.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: classifier,decision-model,drex,guardrails,langchain,langgraph,middleware,nace,router
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: <4.0,>=3.11
22
+ Requires-Dist: httpx<1.0.0,>=0.27.0
23
+ Requires-Dist: langchain-core<2.0.0,>=1.6.13
24
+ Requires-Dist: nace-sdk<1.0.0,>=0.3.0
25
+ Requires-Dist: pydantic<3.0.0,>=2.11
26
+ Provides-Extra: experimental
27
+ Requires-Dist: langchain<2.0.0,>=1.4.0; extra == 'experimental'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # langchain-nace
31
+
32
+ LangChain integration for [Drex](https://console.nace.ai/docs), Nace's decision models. Drex reads text, JSON, LangChain messages and (on Drex Max) images, and returns typed answers with calibrated probabilities instead of generated text. Use it to route requests, pick models, or check tool calls before they run.
33
+
34
+ ```bash
35
+ pip install langchain-nace # classifier, router, LangGraph edge
36
+ pip install "langchain-nace[experimental]" # + agent middleware
37
+ export NACE_API_KEY=nace_sk_...
38
+ ```
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ from langchain_nace import Choice, NaceClassifier, Noul, Score
44
+
45
+ classifier = NaceClassifier() # model="drex-max" by default
46
+
47
+ response = classifier.invoke({
48
+ "state": "I was charged twice for March and I'm annoyed. Please refund one.",
49
+ "questions": {
50
+ "refund": Noul(instructions="Is the customer asking for a refund?"),
51
+ "team": Choice(
52
+ instructions="Which team should handle this?",
53
+ criteria={"billing": "Payments and refunds", "technical": "Bugs"},
54
+ ),
55
+ "mood": Score(instructions="How upset is the customer?", criteria=["calm", "annoyed", "furious"]),
56
+ },
57
+ })
58
+ response.nouls["refund"].noul # 0.98: probability of yes
59
+ response.choices["team"].probabilities # {"billing": 0.99, "technical": 0.01}
60
+ response.scores["mood"].score # 1.1: expected level
61
+ ```
62
+
63
+ All questions about one state go in one request. Answers keep their full probabilities, so thresholds stay in your code. `ainvoke`, `batch` and `abatch` work as on any Runnable. `batch` runs at most 8 requests at once unless you set `max_concurrency`.
64
+
65
+ **Switching from `langchain-typesafe`:** change the import to `from langchain_nace import ...` and `TypeSafeClassifier` to `NaceClassifier`. `Noul`, `Choice`, `Score`, `NoulCriteria` and the response fields (`nouls`, `choices`, `scores`, `usage`, `request_id`) have the same names.
66
+
67
+ ## Models
68
+
69
+ The default is **`drex-max`**: it reads images, and takes up to 16 questions per request. For text-only work with more questions per request (up to 512), use `NaceClassifier(model="drex-v2.0")`. Any other model id is accepted and sent as given (`drex-v1.5`, `drex-latest`, and models released later); Drex enforces each model's limits, and errors tell you what to change. Pin a default for your app with `model=` or `NACE_DEFAULT_DECISION_MODEL`. The package default moves to the newest flagship with each Drex release, so pin a model if you need results that never change.
70
+
71
+ ## Images (Drex Max)
72
+
73
+ ```python
74
+ max_ = NaceClassifier() # drex-max
75
+ max_.invoke({
76
+ "state": "Two product photos.",
77
+ "images": [red_png_bytes, "data:image/jpeg;base64,..."],
78
+ "questions": {"damaged": Choice(instructions="Which image shows damage?",
79
+ criteria={"Image 1": None, "Image 2": None})},
80
+ })
81
+ ```
82
+
83
+ Images can be bytes, paths, base64, data URLs, LangChain image blocks (`{"type": "image", "base64": ...}`, `source_type: "base64"`, `image_url`), or files uploaded with `nace_sdk` (`{"type": "workspace_file", ...}`). Images inside LangChain messages in `state` are moved to `images` automatically and referred to as "Image N". Drex doesn't fetch URLs, so remote image URLs are refused. Sending images to a text-only model (`drex-v2.0`, `drex-v1.5`) raises `NaceImageModelError` before anything is sent; images are never dropped silently.
84
+
85
+ ## Routing
86
+
87
+ ```python
88
+ from langchain_nace import NaceRouter
89
+
90
+ router = NaceRouter(classifier=classifier, question=Choice(
91
+ instructions="Which team?", criteria={"billing": None, "technical": None}),
92
+ min_confidence=0.6, fallback="human")
93
+ chain = router.branch({"billing": billing_chain, "technical": tech_chain, "human": escalate},
94
+ default=escalate)
95
+ ```
96
+
97
+ LangGraph: `nace_conditional_edge(classifier, question, state=..., min_confidence=0.7)` is a path function for `add_conditional_edges`. Below the threshold it returns `"human_review"`, so that node can `interrupt()` for a person. See [`examples/langgraph_router.py`](https://github.com/nace-ai/langchain-nace/blob/main/examples/langgraph_router.py).
98
+
99
+ ## Agent middleware (experimental)
100
+
101
+ ```python
102
+ from langchain.agents import create_agent
103
+ from langgraph.checkpoint.memory import InMemorySaver
104
+ from langchain_nace.experimental.middleware import (
105
+ ModelChoice, NaceAutoModeMiddleware, NaceModelRouterMiddleware)
106
+
107
+ router = NaceModelRouterMiddleware(
108
+ choices={"fast": ModelChoice("openai:gpt-5-mini", "Short lookups"),
109
+ "strong": ModelChoice("anthropic:claude-opus-5-5", "Multi-step changes")},
110
+ instructions="Pick the cheapest model that can do this safely.",
111
+ )
112
+ gate = NaceAutoModeMiddleware(screenshots=True) # drex-max reads the screenshots
113
+ agent = create_agent("openai:gpt-5-mini", tools=tools, middleware=[router, gate],
114
+ checkpointer=InMemorySaver())
115
+ ```
116
+
117
+ **`NaceModelRouterMiddleware`** picks the model once per run. Below `min_confidence` (0.6), or if Drex can't be reached, it uses `escalate_to` (the last choice by default). The decision and probabilities are kept in state under `nace_model_route`.
118
+
119
+ **`NaceAutoModeMiddleware`** checks each tool call before it runs. In one request Drex reads:
120
+ - the user's **original request**, kept however long the run gets;
121
+ - the latest user message and the last 20 messages;
122
+ - the tool call and its description;
123
+ - with `screenshots=True` and Drex Max, the newest screenshots.
124
+
125
+ It answers three questions: a risk score, "destructive?" and "did the user ask for this?". The default `ThresholdPolicy` then decides:
126
+ - **block** at risk ≥ 0.75, or when the call is destructive and the user didn't ask for it;
127
+ - **ask a person** at risk ≥ 0.4, or when the call is destructive and the user asked for it;
128
+ - **allow** otherwise.
129
+
130
+ Asking uses a LangGraph `interrupt`. Resume it with `{"type": "approve"}`, `{"type": "reject", "message": ...}` or `{"type": "edit", "args": {...}}`. If Drex fails, the call is blocked (`on_error="block"`). Questions, thresholds and the policy are all replaceable. Write your own `GatePolicy`, or subclass `GateDecision`.
131
+
132
+ [`examples/support_agent.py`](https://github.com/nace-ai/langchain-nace/blob/main/examples/support_agent.py) runs four live scenarios: an allowed read, a blocked unrequested delete, a person approving a requested delete, and a screenshot-based block on a computer-use click.
133
+
134
+ ## Backends
135
+
136
+ | Backend | `base_url` | Notes |
137
+ |---|---|---|
138
+ | Nace (default) | `https://console.nace.ai` | `NACE_BASE_URL` |
139
+ | OpenRouter | `https://openrouter.ai/api/v1` | models `nace-ai/drex-v2.0`, `nace-ai/drex-max`; images are sent the way OpenRouter expects |
140
+ | Vercel AI Gateway / TypeSafe-compatible hosts | the host's System One root | same request format as Nace |
141
+
142
+ Every request sends `User-Agent` and `X-Nace-Client: langchain-nace/<version>`. On OpenRouter it also sends `HTTP-Referer` and `X-OpenRouter-Title`.
143
+
144
+ ## Errors
145
+
146
+ Errors also inherit LangChain's `Model*Error` classes, and messages never include your state:
147
+
148
+ | Error | When |
149
+ |---|---|
150
+ | `NaceAuthenticationError` | 401 |
151
+ | `NaceInsufficientCreditError` | 402, with the Billing link |
152
+ | `NaceUnprocessableEntityError` | 422; lists each issue's `path: message` |
153
+ | `NaceRateLimitError` | 429, after retries |
154
+ | `NaceOverloadedError` | 529, after retries |
155
+ | `NaceAPIConnectionError` / `NaceAPITimeoutError` | network problems |
156
+
157
+ 429, 529 and 5xx responses are retried (`max_retries=2`), waiting as long as `retry-after-ms` asks. The timeout is 60 s, because Drex may take up to 55 s.
158
+
159
+ ## Tracing
160
+
161
+ Calls show up in LangSmith as `llm` runs (`ls_provider="nace"`) with the questions, the full answers and probabilities, token usage and the Drex request id. The router and middlewares add their thresholds to the run metadata.
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ uv sync --all-extras --all-groups
167
+ make test lint typecheck # offline: fake Drex server
168
+ NACE_API_KEY_LIVE=nace_sk_... make integration_tests # live contract tests
169
+ ```
@@ -0,0 +1,140 @@
1
+ # langchain-nace
2
+
3
+ LangChain integration for [Drex](https://console.nace.ai/docs), Nace's decision models. Drex reads text, JSON, LangChain messages and (on Drex Max) images, and returns typed answers with calibrated probabilities instead of generated text. Use it to route requests, pick models, or check tool calls before they run.
4
+
5
+ ```bash
6
+ pip install langchain-nace # classifier, router, LangGraph edge
7
+ pip install "langchain-nace[experimental]" # + agent middleware
8
+ export NACE_API_KEY=nace_sk_...
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from langchain_nace import Choice, NaceClassifier, Noul, Score
15
+
16
+ classifier = NaceClassifier() # model="drex-max" by default
17
+
18
+ response = classifier.invoke({
19
+ "state": "I was charged twice for March and I'm annoyed. Please refund one.",
20
+ "questions": {
21
+ "refund": Noul(instructions="Is the customer asking for a refund?"),
22
+ "team": Choice(
23
+ instructions="Which team should handle this?",
24
+ criteria={"billing": "Payments and refunds", "technical": "Bugs"},
25
+ ),
26
+ "mood": Score(instructions="How upset is the customer?", criteria=["calm", "annoyed", "furious"]),
27
+ },
28
+ })
29
+ response.nouls["refund"].noul # 0.98: probability of yes
30
+ response.choices["team"].probabilities # {"billing": 0.99, "technical": 0.01}
31
+ response.scores["mood"].score # 1.1: expected level
32
+ ```
33
+
34
+ All questions about one state go in one request. Answers keep their full probabilities, so thresholds stay in your code. `ainvoke`, `batch` and `abatch` work as on any Runnable. `batch` runs at most 8 requests at once unless you set `max_concurrency`.
35
+
36
+ **Switching from `langchain-typesafe`:** change the import to `from langchain_nace import ...` and `TypeSafeClassifier` to `NaceClassifier`. `Noul`, `Choice`, `Score`, `NoulCriteria` and the response fields (`nouls`, `choices`, `scores`, `usage`, `request_id`) have the same names.
37
+
38
+ ## Models
39
+
40
+ The default is **`drex-max`**: it reads images, and takes up to 16 questions per request. For text-only work with more questions per request (up to 512), use `NaceClassifier(model="drex-v2.0")`. Any other model id is accepted and sent as given (`drex-v1.5`, `drex-latest`, and models released later); Drex enforces each model's limits, and errors tell you what to change. Pin a default for your app with `model=` or `NACE_DEFAULT_DECISION_MODEL`. The package default moves to the newest flagship with each Drex release, so pin a model if you need results that never change.
41
+
42
+ ## Images (Drex Max)
43
+
44
+ ```python
45
+ max_ = NaceClassifier() # drex-max
46
+ max_.invoke({
47
+ "state": "Two product photos.",
48
+ "images": [red_png_bytes, "data:image/jpeg;base64,..."],
49
+ "questions": {"damaged": Choice(instructions="Which image shows damage?",
50
+ criteria={"Image 1": None, "Image 2": None})},
51
+ })
52
+ ```
53
+
54
+ Images can be bytes, paths, base64, data URLs, LangChain image blocks (`{"type": "image", "base64": ...}`, `source_type: "base64"`, `image_url`), or files uploaded with `nace_sdk` (`{"type": "workspace_file", ...}`). Images inside LangChain messages in `state` are moved to `images` automatically and referred to as "Image N". Drex doesn't fetch URLs, so remote image URLs are refused. Sending images to a text-only model (`drex-v2.0`, `drex-v1.5`) raises `NaceImageModelError` before anything is sent; images are never dropped silently.
55
+
56
+ ## Routing
57
+
58
+ ```python
59
+ from langchain_nace import NaceRouter
60
+
61
+ router = NaceRouter(classifier=classifier, question=Choice(
62
+ instructions="Which team?", criteria={"billing": None, "technical": None}),
63
+ min_confidence=0.6, fallback="human")
64
+ chain = router.branch({"billing": billing_chain, "technical": tech_chain, "human": escalate},
65
+ default=escalate)
66
+ ```
67
+
68
+ LangGraph: `nace_conditional_edge(classifier, question, state=..., min_confidence=0.7)` is a path function for `add_conditional_edges`. Below the threshold it returns `"human_review"`, so that node can `interrupt()` for a person. See [`examples/langgraph_router.py`](https://github.com/nace-ai/langchain-nace/blob/main/examples/langgraph_router.py).
69
+
70
+ ## Agent middleware (experimental)
71
+
72
+ ```python
73
+ from langchain.agents import create_agent
74
+ from langgraph.checkpoint.memory import InMemorySaver
75
+ from langchain_nace.experimental.middleware import (
76
+ ModelChoice, NaceAutoModeMiddleware, NaceModelRouterMiddleware)
77
+
78
+ router = NaceModelRouterMiddleware(
79
+ choices={"fast": ModelChoice("openai:gpt-5-mini", "Short lookups"),
80
+ "strong": ModelChoice("anthropic:claude-opus-5-5", "Multi-step changes")},
81
+ instructions="Pick the cheapest model that can do this safely.",
82
+ )
83
+ gate = NaceAutoModeMiddleware(screenshots=True) # drex-max reads the screenshots
84
+ agent = create_agent("openai:gpt-5-mini", tools=tools, middleware=[router, gate],
85
+ checkpointer=InMemorySaver())
86
+ ```
87
+
88
+ **`NaceModelRouterMiddleware`** picks the model once per run. Below `min_confidence` (0.6), or if Drex can't be reached, it uses `escalate_to` (the last choice by default). The decision and probabilities are kept in state under `nace_model_route`.
89
+
90
+ **`NaceAutoModeMiddleware`** checks each tool call before it runs. In one request Drex reads:
91
+ - the user's **original request**, kept however long the run gets;
92
+ - the latest user message and the last 20 messages;
93
+ - the tool call and its description;
94
+ - with `screenshots=True` and Drex Max, the newest screenshots.
95
+
96
+ It answers three questions: a risk score, "destructive?" and "did the user ask for this?". The default `ThresholdPolicy` then decides:
97
+ - **block** at risk ≥ 0.75, or when the call is destructive and the user didn't ask for it;
98
+ - **ask a person** at risk ≥ 0.4, or when the call is destructive and the user asked for it;
99
+ - **allow** otherwise.
100
+
101
+ Asking uses a LangGraph `interrupt`. Resume it with `{"type": "approve"}`, `{"type": "reject", "message": ...}` or `{"type": "edit", "args": {...}}`. If Drex fails, the call is blocked (`on_error="block"`). Questions, thresholds and the policy are all replaceable. Write your own `GatePolicy`, or subclass `GateDecision`.
102
+
103
+ [`examples/support_agent.py`](https://github.com/nace-ai/langchain-nace/blob/main/examples/support_agent.py) runs four live scenarios: an allowed read, a blocked unrequested delete, a person approving a requested delete, and a screenshot-based block on a computer-use click.
104
+
105
+ ## Backends
106
+
107
+ | Backend | `base_url` | Notes |
108
+ |---|---|---|
109
+ | Nace (default) | `https://console.nace.ai` | `NACE_BASE_URL` |
110
+ | OpenRouter | `https://openrouter.ai/api/v1` | models `nace-ai/drex-v2.0`, `nace-ai/drex-max`; images are sent the way OpenRouter expects |
111
+ | Vercel AI Gateway / TypeSafe-compatible hosts | the host's System One root | same request format as Nace |
112
+
113
+ Every request sends `User-Agent` and `X-Nace-Client: langchain-nace/<version>`. On OpenRouter it also sends `HTTP-Referer` and `X-OpenRouter-Title`.
114
+
115
+ ## Errors
116
+
117
+ Errors also inherit LangChain's `Model*Error` classes, and messages never include your state:
118
+
119
+ | Error | When |
120
+ |---|---|
121
+ | `NaceAuthenticationError` | 401 |
122
+ | `NaceInsufficientCreditError` | 402, with the Billing link |
123
+ | `NaceUnprocessableEntityError` | 422; lists each issue's `path: message` |
124
+ | `NaceRateLimitError` | 429, after retries |
125
+ | `NaceOverloadedError` | 529, after retries |
126
+ | `NaceAPIConnectionError` / `NaceAPITimeoutError` | network problems |
127
+
128
+ 429, 529 and 5xx responses are retried (`max_retries=2`), waiting as long as `retry-after-ms` asks. The timeout is 60 s, because Drex may take up to 55 s.
129
+
130
+ ## Tracing
131
+
132
+ Calls show up in LangSmith as `llm` runs (`ls_provider="nace"`) with the questions, the full answers and probabilities, token usage and the Drex request id. The router and middlewares add their thresholds to the run metadata.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ uv sync --all-extras --all-groups
138
+ make test lint typecheck # offline: fake Drex server
139
+ NACE_API_KEY_LIVE=nace_sk_... make integration_tests # live contract tests
140
+ ```
@@ -0,0 +1,81 @@
1
+ """LangChain integration for Drex decision models by Nace."""
2
+
3
+ from langchain_nace._version import __version__
4
+ from langchain_nace.classifier import NaceClassifier
5
+ from langchain_nace.errors import (
6
+ NaceAPIConnectionError,
7
+ NaceAPIError,
8
+ NaceAPIResponseValidationError,
9
+ NaceAPITimeoutError,
10
+ NaceAuthenticationError,
11
+ NaceBadRequestError,
12
+ NaceError,
13
+ NaceImageError,
14
+ NaceImageModelError,
15
+ NaceInsufficientCreditError,
16
+ NaceInternalServerError,
17
+ NaceMediaError,
18
+ NaceNotFoundError,
19
+ NaceOverloadedError,
20
+ NacePermissionDeniedError,
21
+ NaceRateLimitError,
22
+ NaceUnprocessableEntityError,
23
+ )
24
+ from langchain_nace.graph import nace_conditional_edge
25
+ from langchain_nace.router import NaceLowConfidenceError, NaceRouter, RouteDecision
26
+ from langchain_nace.types import (
27
+ Answer,
28
+ Choice,
29
+ ChoiceAnswer,
30
+ ClassifierRequest,
31
+ ClassifierResponse,
32
+ Noul,
33
+ NoulAnswer,
34
+ NoulCriteria,
35
+ Question,
36
+ RawAnswer,
37
+ Score,
38
+ ScoreAnswer,
39
+ State,
40
+ Usage,
41
+ )
42
+
43
+ __all__ = [
44
+ "Answer",
45
+ "Choice",
46
+ "ChoiceAnswer",
47
+ "ClassifierRequest",
48
+ "ClassifierResponse",
49
+ "NaceAPIConnectionError",
50
+ "NaceAPIError",
51
+ "NaceAPIResponseValidationError",
52
+ "NaceAPITimeoutError",
53
+ "NaceAuthenticationError",
54
+ "NaceBadRequestError",
55
+ "NaceClassifier",
56
+ "NaceError",
57
+ "NaceImageError",
58
+ "NaceImageModelError",
59
+ "NaceInsufficientCreditError",
60
+ "NaceInternalServerError",
61
+ "NaceLowConfidenceError",
62
+ "NaceMediaError",
63
+ "NaceNotFoundError",
64
+ "NaceOverloadedError",
65
+ "NacePermissionDeniedError",
66
+ "NaceRateLimitError",
67
+ "NaceRouter",
68
+ "NaceUnprocessableEntityError",
69
+ "Noul",
70
+ "NoulAnswer",
71
+ "NoulCriteria",
72
+ "Question",
73
+ "RawAnswer",
74
+ "RouteDecision",
75
+ "Score",
76
+ "ScoreAnswer",
77
+ "State",
78
+ "Usage",
79
+ "__version__",
80
+ "nace_conditional_edge",
81
+ ]
@@ -0,0 +1,124 @@
1
+ """Convert images to the entries Drex reads in a request's `images` array."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import os
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ import nace_sdk
12
+
13
+ from langchain_nace.errors import NaceImageError
14
+
15
+ _REMOTE = ("http://", "https://")
16
+
17
+
18
+ def _remote_url_error(url: str) -> NaceImageError:
19
+ return NaceImageError(
20
+ f"Drex doesn't fetch image URLs ({url[:60]}…). Download the image and pass its "
21
+ "bytes, or upload it with `nace_sdk` and pass the uploaded file."
22
+ )
23
+
24
+
25
+ def _from_url(url: str) -> str:
26
+ if url.startswith("data:"):
27
+ return url
28
+ if url.startswith(_REMOTE):
29
+ raise _remote_url_error(url)
30
+ return url
31
+
32
+
33
+ def _from_base64(data: str, mime_type: str | None) -> str:
34
+ return f"data:{mime_type};base64,{data}" if mime_type else data
35
+
36
+
37
+ _SIGNATURES = (
38
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
39
+ (b"\xff\xd8\xff", "image/jpeg"),
40
+ (b"GIF8", "image/gif"),
41
+ )
42
+
43
+
44
+ def as_data_url(image: str) -> str:
45
+ """A data URL for base64 image bytes, typed from their first bytes."""
46
+ if image.startswith("data:"):
47
+ return image
48
+ head = base64.b64decode(image[:24] + "=" * (-len(image[:24]) % 4), validate=False)
49
+ mime = next((m for sig, m in _SIGNATURES if head.startswith(sig)), None)
50
+ if mime is None and head[:4] == b"RIFF" and head[8:12] == b"WEBP":
51
+ mime = "image/webp"
52
+ return f"data:{mime or 'application/octet-stream'};base64,{image}"
53
+
54
+
55
+ def _is_base64(value: str) -> bool:
56
+ if len(value) % 4:
57
+ return False
58
+ try:
59
+ base64.b64decode(value, validate=True)
60
+ except (binascii.Error, ValueError):
61
+ return False
62
+ return True
63
+
64
+
65
+ def to_drex_image(item: Any) -> str | dict[str, Any]:
66
+ """Return one `images` entry for Drex.
67
+
68
+ Accepts base64, a data URL, bytes, a path, a LangChain image content block (v0
69
+ `source_type`, v1 `base64`/`url`), an `image_url` block, a `workspace_file`
70
+ reference, or an `UploadedFile`. Remote URLs are refused: Drex never fetches them.
71
+
72
+ Raises:
73
+ NaceImageError: The item isn't an image Drex can read.
74
+
75
+ """
76
+ if isinstance(item, str):
77
+ if item.startswith(("data:", *_REMOTE)):
78
+ return _from_url(item)
79
+ if _is_base64(item):
80
+ return item
81
+ if os.path.exists(item):
82
+ return _from_sdk(item)
83
+ message = f"{item[:60]!r} is not base64, a data URL, or an existing file path."
84
+ raise NaceImageError(message)
85
+ if isinstance(item, bytes | bytearray | os.PathLike):
86
+ return _from_sdk(item)
87
+ if isinstance(item, nace_sdk.UploadedFile | nace_sdk.WorkspaceFileSource):
88
+ return _from_sdk(item)
89
+ if isinstance(item, Mapping):
90
+ return _from_block(item)
91
+ message = f"Unsupported image type: {type(item).__name__}."
92
+ raise NaceImageError(message)
93
+
94
+
95
+ def _from_sdk(item: Any) -> str | dict[str, Any]:
96
+ try:
97
+ return nace_sdk.image_input(item)
98
+ except nace_sdk.NaceError as error:
99
+ raise NaceImageError(str(error)) from error
100
+
101
+
102
+ def _from_block(block: Mapping[str, Any]) -> str | dict[str, Any]:
103
+ kind = block.get("type")
104
+ if kind == "workspace_file":
105
+ return dict(block)
106
+ if kind == "image_url":
107
+ image_url = block.get("image_url")
108
+ url = image_url.get("url") if isinstance(image_url, Mapping) else image_url
109
+ if isinstance(url, str):
110
+ return _from_url(url)
111
+ if kind == "image":
112
+ mime_type = block.get("mime_type")
113
+ if block.get("source_type") == "base64" and isinstance(block.get("data"), str):
114
+ return _from_base64(block["data"], mime_type)
115
+ if isinstance(block.get("base64"), str):
116
+ return _from_base64(block["base64"], mime_type)
117
+ url = block.get("url") or (block.get("data") if block.get("source_type") == "url" else None)
118
+ if isinstance(url, str):
119
+ return _from_url(url)
120
+ message = f"Unsupported image block: {dict(block)!r:.80}"
121
+ raise NaceImageError(message)
122
+
123
+
124
+ __all__ = ["as_data_url", "to_drex_image"]
@@ -0,0 +1,119 @@
1
+ """Turn state containing LangChain messages into the JSON Drex reads.
2
+
3
+ Drex rejects media inside `state`. Images found in messages are either moved to the
4
+ request's `images` array (replaced in the text by "[Image N]"), replaced by a
5
+ placeholder, or refused, depending on the caller. Other media (audio, files) is
6
+ replaced by a placeholder or refused.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Literal
15
+
16
+ from langchain_core.messages import BaseMessage, convert_to_openai_messages
17
+
18
+ from langchain_nace._images import to_drex_image
19
+ from langchain_nace.errors import NaceImageModelError, NaceMediaError
20
+
21
+ ImageMode = Literal["lift", "placeholder", "error"]
22
+ MediaMode = Literal["placeholder", "error"]
23
+
24
+
25
+ @dataclass
26
+ class _Encoder:
27
+ images: ImageMode
28
+ media: MediaMode
29
+ first_index: int
30
+ lifted: list[str | dict[str, Any]] = field(default_factory=list)
31
+
32
+ def value(self, value: Any) -> Any:
33
+ if isinstance(value, BaseMessage):
34
+ with warnings.catch_warnings():
35
+ warnings.simplefilter("ignore")
36
+ converted = convert_to_openai_messages(value)
37
+ return self.message(converted)
38
+ if isinstance(value, dict):
39
+ if not all(isinstance(key, str) for key in value):
40
+ message = "State object keys must be strings."
41
+ raise TypeError(message)
42
+ return {key: self.value(item) for key, item in value.items()}
43
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
44
+ return [self.value(item) for item in value]
45
+ if value is None or isinstance(value, str | int | float | bool):
46
+ return value
47
+ message = f"Unsupported state value: {type(value).__name__}."
48
+ raise TypeError(message)
49
+
50
+ def message(self, message: dict[str, Any]) -> dict[str, Any]:
51
+ content = message.get("content")
52
+ if isinstance(content, list):
53
+ message = {**message, "content": [self.part(part) for part in content]}
54
+ return message
55
+
56
+ def part(self, part: Any) -> Any:
57
+ if not isinstance(part, dict):
58
+ return part
59
+ kind = part.get("type")
60
+ if kind == "text":
61
+ return part
62
+ if kind == "image_url":
63
+ return self.image(part)
64
+ if self.media == "error":
65
+ message = (
66
+ f"Drex reads text and images only; the state holds a `{kind}` content block. "
67
+ "Remove it, or describe it in text."
68
+ )
69
+ raise NaceMediaError(message)
70
+ return {"type": "text", "text": f"[{kind or 'content'} omitted]"}
71
+
72
+ def image(self, part: dict[str, Any]) -> dict[str, Any]:
73
+ if self.images == "placeholder":
74
+ return {"type": "text", "text": "[image omitted]"}
75
+ if self.images == "error":
76
+ message = (
77
+ "The state holds an image, but this model reads text only. "
78
+ "Use an image model such as model='drex-max'."
79
+ )
80
+ raise NaceImageModelError(message)
81
+ self.lifted.append(to_drex_image(part))
82
+ return {"type": "text", "text": f"[Image {self.first_index + len(self.lifted) - 1}]"}
83
+
84
+
85
+ def encode_state(
86
+ state: Any,
87
+ *,
88
+ images: ImageMode,
89
+ media: MediaMode = "error",
90
+ first_index: int = 1,
91
+ ) -> tuple[Any, list[str | dict[str, Any]]]:
92
+ """Return `(state_json, lifted_images)`.
93
+
94
+ Args:
95
+ state: A string, JSON object or array, a message, or messages at any depth.
96
+ images: What to do with images in messages: move them to `images` ("lift"),
97
+ replace them with "[image omitted]", or raise.
98
+ media: What to do with audio, files and other media: replace or raise.
99
+ first_index: Number of the first lifted image, after any explicit images.
100
+
101
+ Raises:
102
+ TypeError: The root is a bare number, boolean or `None`, or a value isn't JSON.
103
+ NaceImageModelError: An image was found and `images="error"`.
104
+ NaceMediaError: Other media was found and `media="error"`.
105
+
106
+ """
107
+ if state is None or isinstance(state, bool | int | float):
108
+ message = "State must be a string, a JSON object or array, or LangChain messages."
109
+ raise TypeError(message)
110
+ encoder = _Encoder(images=images, media=media, first_index=first_index)
111
+ return encoder.value(state), encoder.lifted
112
+
113
+
114
+ def message_text(message: BaseMessage) -> str:
115
+ """The text of a message, with non-text blocks left out."""
116
+ return message.text if isinstance(message.text, str) else str(message.text)
117
+
118
+
119
+ __all__ = ["encode_state", "message_text"]
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.1.0"