logit-classifier 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.
@@ -0,0 +1,285 @@
1
+ """Request and response models mirroring the TypeSafe System One wire format.
2
+
3
+ Field names, nesting and answer shapes follow the published Jev contract so a
4
+ Jev request body is accepted unchanged. Deviations are noted where they occur.
5
+
6
+ Plain dataclasses rather than pydantic, so a ComfyUI node pack importing this
7
+ package never pulls a pydantic version into ComfyUI's own venv.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import asdict, dataclass
14
+ from typing import Any, Literal
15
+
16
+ from .config import JEV_MAX_OPTIONS, JEV_MAX_QUESTIONS, JEV_MAX_SCORE_LEVELS
17
+ from .errors import LogitClassifierError
18
+
19
+ # Jev accepts a string, object or array anywhere prose is expected.
20
+ JSONContent = str | dict[str, Any] | list[Any]
21
+
22
+ DEFAULT_MODEL = "logit-latest"
23
+
24
+ _REQUEST_FIELDS = frozenset({"state", "model", "questions"})
25
+ _QUESTION_FIELDS = frozenset({"type", "instructions", "criteria"})
26
+ _NOUL_CRITERIA_FIELDS = frozenset({"true", "false"})
27
+ _QUESTION_TYPES = ("choice", "score", "noul")
28
+
29
+
30
+ class SchemaError(LogitClassifierError, ValueError):
31
+ """A body does not match the System One contract.
32
+
33
+ `field` carries the dotted path to the offending value, which is what the
34
+ service reports back so a caller can find it without guessing.
35
+ """
36
+
37
+ def __init__(self, message: str, field: str | None = None) -> None:
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.field = field or None
41
+
42
+
43
+ def render_content(value: JSONContent | None) -> str:
44
+ """Flatten a JSONContent value into the text the model sees."""
45
+ if value is None:
46
+ return ""
47
+ if isinstance(value, str):
48
+ return value
49
+ return json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
50
+
51
+
52
+ @dataclass(frozen=True, kw_only=True)
53
+ class NoulCriteria:
54
+ """Wording for the two sides of a noul. `true` and `false` are reserved words."""
55
+
56
+ true_: JSONContent | None = None
57
+ false_: JSONContent | None = None
58
+
59
+
60
+ @dataclass(frozen=True, kw_only=True)
61
+ class ChoiceQuestion:
62
+ """Pick one of the named options."""
63
+
64
+ type: Literal["choice"] = "choice"
65
+ instructions: JSONContent | None = None
66
+ criteria: dict[str, JSONContent | None]
67
+
68
+
69
+ @dataclass(frozen=True, kw_only=True)
70
+ class ScoreQuestion:
71
+ """Place the state on an ordered scale, lowest level first."""
72
+
73
+ type: Literal["score"] = "score"
74
+ instructions: JSONContent | None = None
75
+ criteria: list[JSONContent]
76
+
77
+
78
+ @dataclass(frozen=True, kw_only=True)
79
+ class NoulQuestion:
80
+ """How true one statement is of the state."""
81
+
82
+ type: Literal["noul"] = "noul"
83
+ instructions: JSONContent | None = None
84
+ criteria: NoulCriteria | None = None
85
+
86
+
87
+ Question = ChoiceQuestion | ScoreQuestion | NoulQuestion
88
+
89
+
90
+ @dataclass(frozen=True, kw_only=True)
91
+ class SystemOneRequest:
92
+ """One state, and every question asked about it."""
93
+
94
+ state: JSONContent
95
+ model: str = DEFAULT_MODEL
96
+ questions: dict[str, Question]
97
+
98
+
99
+ # Answer field order matches the published response examples, and asdict follows
100
+ # the order the fields are declared in.
101
+ @dataclass(frozen=True, kw_only=True)
102
+ class ChoiceAnswer:
103
+ type: Literal["choice"] = "choice"
104
+ choice: str
105
+ confidence: float
106
+ probabilities: dict[str, float]
107
+ # How strongly the model wants none of the offered options. It sits outside
108
+ # probabilities, which still covers the caller's own options and sums to 1.
109
+ abstain: float | None = None
110
+
111
+
112
+ @dataclass(frozen=True, kw_only=True)
113
+ class ScoreAnswer:
114
+ type: Literal["score"] = "score"
115
+ score: float
116
+ confidence: float
117
+ legend: dict[str, JSONContent]
118
+ probabilities: dict[str, float]
119
+
120
+
121
+ @dataclass(frozen=True, kw_only=True)
122
+ class NoulAnswer:
123
+ type: Literal["noul"] = "noul"
124
+ noul: float
125
+
126
+
127
+ Answer = ChoiceAnswer | ScoreAnswer | NoulAnswer
128
+
129
+
130
+ @dataclass(frozen=True, kw_only=True)
131
+ class Usage:
132
+ input_tokens: int | None = None
133
+ output_tokens: int | None = None
134
+
135
+
136
+ @dataclass(frozen=True, kw_only=True)
137
+ class SystemOneResponse:
138
+ model: str
139
+ answers: dict[str, Answer]
140
+ usage: Usage
141
+
142
+ def to_dict(self) -> dict[str, Any]:
143
+ return asdict(self)
144
+
145
+
146
+ @dataclass(frozen=True, kw_only=True)
147
+ class ErrorBody:
148
+ """The error shape this service returns.
149
+
150
+ Jev documents its status codes but publishes no error body, so this shape is ours.
151
+ """
152
+
153
+ error: str
154
+ message: str
155
+ field: str | None = None
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ return asdict(self)
159
+
160
+
161
+ def _path(*parts: str) -> str:
162
+ return ".".join(part for part in parts if part)
163
+
164
+
165
+ def _require_mapping(value: Any, where: str) -> dict[str, Any]:
166
+ if not isinstance(value, dict):
167
+ raise SchemaError(f"expected an object, got {type(value).__name__}", where)
168
+ for key in value:
169
+ if not isinstance(key, str):
170
+ raise SchemaError(f"object keys must be strings, got {type(key).__name__}", where)
171
+ return value
172
+
173
+
174
+ def _reject_unknown(mapping: dict[str, Any], allowed: frozenset[str], where: str) -> None:
175
+ unknown = sorted(set(mapping) - allowed)
176
+ if unknown:
177
+ raise SchemaError(f"unexpected field {unknown[0]!r}", _path(where, unknown[0]))
178
+
179
+
180
+ def _content(value: Any, where: str) -> JSONContent:
181
+ if isinstance(value, str | dict | list):
182
+ return value
183
+ raise SchemaError(f"expected a string, object or array, got {type(value).__name__}", where)
184
+
185
+
186
+ def _optional_content(value: Any, where: str) -> JSONContent | None:
187
+ return None if value is None else _content(value, where)
188
+
189
+
190
+ def _choice_criteria(value: Any, where: str) -> dict[str, JSONContent | None]:
191
+ mapping = _require_mapping(value, where)
192
+
193
+ if not 2 <= len(mapping) <= JEV_MAX_OPTIONS:
194
+ raise SchemaError(
195
+ f"a choice needs 2 to {JEV_MAX_OPTIONS} options, got {len(mapping)}", where
196
+ )
197
+ return {name: _optional_content(body, _path(where, name)) for name, body in mapping.items()}
198
+
199
+
200
+ def _score_criteria(value: Any, where: str) -> list[JSONContent]:
201
+ if not isinstance(value, list):
202
+ raise SchemaError(f"expected an array, got {type(value).__name__}", where)
203
+ if not 2 <= len(value) <= JEV_MAX_SCORE_LEVELS:
204
+ raise SchemaError(
205
+ f"a score needs 2 to {JEV_MAX_SCORE_LEVELS} levels, got {len(value)}", where
206
+ )
207
+ return [_content(level, _path(where, str(index))) for index, level in enumerate(value)]
208
+
209
+
210
+ def _noul_criteria(value: Any, where: str) -> NoulCriteria:
211
+ mapping = _require_mapping(value, where)
212
+
213
+ _reject_unknown(mapping, _NOUL_CRITERIA_FIELDS, where)
214
+ return NoulCriteria(
215
+ true_=_optional_content(mapping.get("true"), _path(where, "true")),
216
+ false_=_optional_content(mapping.get("false"), _path(where, "false")),
217
+ )
218
+
219
+
220
+ def _parse_question(value: Any, where: str) -> Question:
221
+ mapping = _require_mapping(value, where)
222
+ kind = mapping.get("type")
223
+ instructions = _optional_content(mapping.get("instructions"), _path(where, "instructions"))
224
+ criteria = mapping.get("criteria")
225
+ criteria_at = _path(where, "criteria")
226
+
227
+ if kind not in _QUESTION_TYPES:
228
+ raise SchemaError(
229
+ f"type must be one of {', '.join(_QUESTION_TYPES)}, got {kind!r}",
230
+ _path(where, "type"),
231
+ )
232
+ _reject_unknown(mapping, _QUESTION_FIELDS, where)
233
+
234
+ if kind == "choice":
235
+ if criteria is None:
236
+ raise SchemaError("a choice needs criteria", criteria_at)
237
+ return ChoiceQuestion(
238
+ instructions=instructions, criteria=_choice_criteria(criteria, criteria_at)
239
+ )
240
+ if kind == "score":
241
+ if criteria is None:
242
+ raise SchemaError("a score needs criteria", criteria_at)
243
+ return ScoreQuestion(
244
+ instructions=instructions, criteria=_score_criteria(criteria, criteria_at)
245
+ )
246
+ return NoulQuestion(
247
+ instructions=instructions,
248
+ criteria=None if criteria is None else _noul_criteria(criteria, criteria_at),
249
+ )
250
+
251
+
252
+ def parse_questions(value: Any) -> dict[str, Question]:
253
+ """Validate a question map on its own, for a caller holding no full request."""
254
+ mapping = _require_mapping(value, "questions")
255
+
256
+ if not 1 <= len(mapping) <= JEV_MAX_QUESTIONS:
257
+ raise SchemaError(
258
+ f"1 to {JEV_MAX_QUESTIONS} questions are allowed, got {len(mapping)}", "questions"
259
+ )
260
+ return {qid: _parse_question(body, _path("questions", qid)) for qid, body in mapping.items()}
261
+
262
+
263
+ def parse_request(body: Any) -> SystemOneRequest:
264
+ """Validate a decoded JSON body and return the request it describes.
265
+
266
+ Every failure raises SchemaError carrying the dotted path to the value that
267
+ failed, so the first rejection names its own field rather than the body.
268
+ """
269
+ mapping = _require_mapping(body, "")
270
+
271
+ _reject_unknown(mapping, _REQUEST_FIELDS, "")
272
+ if "state" not in mapping:
273
+ raise SchemaError("state is required", "state")
274
+ if "questions" not in mapping:
275
+ raise SchemaError("questions is required", "questions")
276
+
277
+ model = mapping.get("model", DEFAULT_MODEL)
278
+ if not isinstance(model, str):
279
+ raise SchemaError(f"expected a string, got {type(model).__name__}", "model")
280
+
281
+ return SystemOneRequest(
282
+ state=_content(mapping["state"], "state"),
283
+ model=model,
284
+ questions=parse_questions(mapping["questions"]),
285
+ )
@@ -0,0 +1,102 @@
1
+ """Turning label logits into calibrated probabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def restricted_softmax(
9
+ z: np.ndarray, log_prior: np.ndarray | None = None, temperature: float = 1.0
10
+ ) -> np.ndarray:
11
+ """Normalise over the option labels only, in float64.
12
+
13
+ Subtracting a log-prior is the shared operation behind batch calibration,
14
+ contextual calibration and PriDe. Temperature then widens the distribution,
15
+ which a restricted softmax over an aligned model is otherwise too sharp for.
16
+ """
17
+ adjusted = z.astype(np.float64)
18
+
19
+ if log_prior is not None:
20
+ adjusted = adjusted - log_prior
21
+ if temperature and temperature != 1.0:
22
+ adjusted = adjusted / temperature
23
+ adjusted = adjusted - adjusted.max()
24
+ weights: np.ndarray = np.exp(adjusted)
25
+ normalised: np.ndarray = weights / weights.sum()
26
+ return normalised
27
+
28
+
29
+ def choice_confidence(probabilities: np.ndarray) -> float:
30
+ """Peak probability rescaled so uniform maps to 0 and a point mass to 1.
31
+
32
+ Ported with score_confidence from TypeSafe's own
33
+ system_one_adapter._utils.confidence_metrics, so a System One client reading
34
+ our answers computes the same number it would from theirs.
35
+ """
36
+ count = len(probabilities)
37
+
38
+ if count < 2:
39
+ return 1.0
40
+ peak = float(probabilities.max())
41
+ return float(np.clip((count * peak - 1.0) / (count - 1.0), 0.0, 1.0))
42
+
43
+
44
+ def score_confidence(probabilities: np.ndarray) -> float:
45
+ """Concentration of an ordered distribution around its modal level.
46
+
47
+ Levels are ordinal, so mass one level from the mode counts less than mass
48
+ four levels away. The choice formula cannot express that, and it reports a
49
+ tight two level split as low confidence.
50
+ """
51
+ count = len(probabilities)
52
+
53
+ if count < 2:
54
+ return 1.0
55
+ indices = np.arange(count, dtype=np.float64)
56
+ spread = float(probabilities @ np.abs(indices - int(np.argmax(probabilities))))
57
+ uniform_spread = float(np.abs(indices - (count - 1) / 2.0).mean())
58
+ return float(max(0.0, 1.0 - spread / uniform_spread))
59
+
60
+
61
+ def normalise_levels(yes_probabilities: np.ndarray) -> np.ndarray:
62
+ """Turn independent per-level yes probabilities into one distribution."""
63
+ total = float(yes_probabilities.sum())
64
+
65
+ if total <= 0.0:
66
+ return np.full(len(yes_probabilities), 1.0 / len(yes_probabilities))
67
+ return yes_probabilities / total
68
+
69
+
70
+ def expected_score(level_probabilities: np.ndarray) -> float:
71
+ """Probability-weighted mean of the zero-based level indices."""
72
+ return float(level_probabilities @ np.arange(len(level_probabilities), dtype=np.float64))
73
+
74
+
75
+ def combine_escape(group_probabilities: list[np.ndarray]) -> tuple[np.ndarray, float]:
76
+ """Merge groups whose last label is "none of these" into one distribution.
77
+
78
+ A group's weight is the mass it did not put on its escape label, so the
79
+ groups compete without ever being summarised for the model. That removes the
80
+ separate group branch, and with it the label bias that branch carried.
81
+
82
+ The second return is the abstain score, which is how hard the least declining
83
+ group declined. Every group has to decline for the question to be unanswerable,
84
+ so the weakest decline is the binding one. It is reported beside the distribution
85
+ rather than inside it.
86
+ """
87
+ weights = np.array([1.0 - float(p[-1]) for p in group_probabilities], dtype=np.float64)
88
+ parts: list[np.ndarray] = []
89
+
90
+ if weights.sum() <= 0:
91
+ weights = np.ones(len(group_probabilities), dtype=np.float64)
92
+ weights = weights / weights.sum()
93
+
94
+ for weight, probabilities in zip(weights, group_probabilities, strict=True):
95
+ members = probabilities[:-1]
96
+ total = members.sum()
97
+ share = members / total if total > 0 else np.full(len(members), 1.0 / len(members))
98
+ parts.append(weight * share)
99
+ combined = np.concatenate(parts)
100
+ total = combined.sum()
101
+ declined = float(min(float(p[-1]) for p in group_probabilities))
102
+ return (combined / total if total > 0 else combined), declined
@@ -0,0 +1,174 @@
1
+ """HTTP service exposing the Jev-shaped System One endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Annotated, Any
13
+
14
+ from fastapi import Body, FastAPI, Query, Request
15
+ from fastapi.exceptions import RequestValidationError
16
+ from fastapi.responses import FileResponse, JSONResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ from . import __version__
20
+ from .backends.base import VisionUnsupportedError
21
+ from .classifier import Classifier
22
+ from .config import Config
23
+ from .errors import LogitClassifierError
24
+ from .schema import ErrorBody, SchemaError, parse_request
25
+ from .vision import ImageError
26
+
27
+ WEB_DIR = Path(__file__).parent / "web"
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+ EXAMPLE_REQUEST = {
32
+ "state": "The integration keeps failing and I am losing sales.",
33
+ "questions": {
34
+ "department": {
35
+ "type": "choice",
36
+ "instructions": "Which team should handle this",
37
+ "criteria": {"billing": "Payment issues", "technical": "Integration problems"},
38
+ }
39
+ },
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Loaded:
45
+ """What the lifespan built, present only once the model is resident."""
46
+
47
+ config: Config
48
+ classifier: Classifier
49
+ load_seconds: float
50
+
51
+
52
+ _loaded: Loaded | None = None
53
+ # One GPU, and batch composition must stay a pure function of the request, so
54
+ # requests are served one at a time.
55
+ _gpu_lock = threading.Lock()
56
+
57
+
58
+ @asynccontextmanager
59
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
60
+ global _loaded
61
+ config = Config.from_env()
62
+ started = time.time()
63
+ classifier = Classifier(config)
64
+ _loaded = Loaded(config=config, classifier=classifier,
65
+ load_seconds=round(time.time() - started, 2))
66
+ yield
67
+ classifier.persist_prior()
68
+
69
+
70
+ app = FastAPI(title="Logit Classifier", version=__version__, lifespan=lifespan)
71
+
72
+
73
+ def _rejected(message: str, field: str | None = None) -> JSONResponse:
74
+ body = ErrorBody(error="unprocessable_entity", message=message, field=field)
75
+ return JSONResponse(status_code=422, content=body.to_dict())
76
+
77
+
78
+ def _failed(message: str) -> JSONResponse:
79
+ body = ErrorBody(error="internal_error", message=message)
80
+ return JSONResponse(status_code=500, content=body.to_dict())
81
+
82
+
83
+ def _unavailable() -> JSONResponse:
84
+ body = ErrorBody(error="service_unavailable", message="model is still loading")
85
+ return JSONResponse(status_code=503, content=body.to_dict())
86
+
87
+
88
+ @app.exception_handler(RequestValidationError)
89
+ async def on_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
90
+ first = exc.errors()[0] if exc.errors() else {}
91
+ location = ".".join(str(p) for p in first.get("loc", []) if p != "body")
92
+ return _rejected(first.get("msg", "request failed validation"), location or None)
93
+
94
+
95
+ @app.post("/v1/systemone")
96
+ def system_one(
97
+ body: Annotated[dict[str, Any], Body(openapi_examples={"jev": {"value": EXAMPLE_REQUEST}})],
98
+ diagnostics: Annotated[bool, Query()] = False,
99
+ ) -> Any:
100
+ loaded = _loaded
101
+
102
+ if loaded is None:
103
+ return _unavailable()
104
+ try:
105
+ request = parse_request(body)
106
+ except SchemaError as error:
107
+ return _rejected(error.message, error.field)
108
+
109
+ started = time.time()
110
+ try:
111
+ with _gpu_lock:
112
+ response, diag = loaded.classifier.classify(request, allow_image_paths=False)
113
+ except (ImageError, VisionUnsupportedError) as error:
114
+ return _rejected(str(error), "state")
115
+ except LogitClassifierError as error:
116
+ # A backend contract or label failure is ours, not the caller's, so it must
117
+ # not come back as a rejected request they could fix. The traceback is the
118
+ # operator's only diagnostic, and returning a body swallows it.
119
+ _log.exception("classify failed")
120
+ return _failed(str(error))
121
+ except Exception as error:
122
+ # A CUDA out of memory or a kernel error must still reach a Jev caller as an
123
+ # ErrorBody, never as a plain-text 500.
124
+ _log.exception("classify failed")
125
+ return _failed(f"{type(error).__name__}: {error}")
126
+ payload = response.to_dict()
127
+
128
+ if diagnostics:
129
+ payload["diagnostics"] = {
130
+ "candidate_mass": diag.candidate_mass,
131
+ "prior_applied": diag.prior_applied,
132
+ "branch_counts": diag.branch_counts,
133
+ "latency_ms": round((time.time() - started) * 1000, 1),
134
+ "temperature": loaded.classifier.temperature,
135
+ "score_method": loaded.config.score_method,
136
+ "prior_observations": loaded.classifier.priors.stats(),
137
+ }
138
+ return payload
139
+
140
+
141
+ @app.get("/v1/models")
142
+ def models() -> Any:
143
+ loaded = _loaded
144
+
145
+ if loaded is None:
146
+ return _unavailable()
147
+ return {
148
+ "models": [
149
+ {
150
+ "name": loaded.config.served_model_id,
151
+ "description": f"Restricted-label scorer over {loaded.config.model_id}",
152
+ "aliases": list(loaded.config.model_aliases),
153
+ }
154
+ ]
155
+ }
156
+
157
+
158
+ @app.get("/healthz")
159
+ def healthz() -> dict[str, Any]:
160
+ loaded = _loaded
161
+ return {
162
+ "ready": loaded is not None,
163
+ "model_id": loaded.config.model_id if loaded else None,
164
+ "sees_images": loaded.classifier.backend.sees_images if loaded else None,
165
+ "load_seconds": loaded.load_seconds if loaded else None,
166
+ }
167
+
168
+
169
+ @app.get("/")
170
+ def index() -> FileResponse:
171
+ return FileResponse(WEB_DIR / "index.html")
172
+
173
+
174
+ app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
@@ -0,0 +1,95 @@
1
+ """Pulling an image out of the request state.
2
+
3
+ The state stays JSON, so an image arrives as a data URL, a bare base64 string or a
4
+ path. It is lifted out before the state is rendered as text, because base64 in the
5
+ prompt would cost thousands of tokens and say nothing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import io
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any, cast
15
+
16
+ from .deps import require
17
+ from .errors import LogitClassifierError
18
+
19
+ if TYPE_CHECKING:
20
+ from PIL.Image import Image
21
+
22
+ IMAGE_KEYS = ("image", "screenshot")
23
+
24
+
25
+ class ImageError(LogitClassifierError, ValueError):
26
+ """The state named an image that could not be read."""
27
+
28
+
29
+ def _from_base64(value: str) -> Image:
30
+ pil_image = require("PIL.Image", "service")
31
+
32
+ try:
33
+ raw = base64.b64decode(value, validate=True)
34
+ except (binascii.Error, ValueError) as error:
35
+ raise ImageError(f"image is neither a readable path nor valid base64: {error}") from error
36
+ try:
37
+ return cast("Image", pil_image.open(io.BytesIO(raw)).convert("RGB"))
38
+ except (OSError, ValueError, pil_image.DecompressionBombError) as error:
39
+ raise ImageError(f"decoded bytes are not a readable image: {error}") from error
40
+
41
+
42
+ def _decode(value: str, key: str, *, allow_paths: bool) -> Image:
43
+ """Decode a data URL, a path to a file, or bare base64, by trying rather than guessing.
44
+
45
+ Length cannot tell a path from base64, since a small PNG encodes shorter than many
46
+ paths. Whether the file exists can.
47
+ """
48
+ path: Path | None = None
49
+ pil_image = require("PIL.Image", "service")
50
+
51
+ if value.startswith("data:"):
52
+ _, _, payload = value.partition(",")
53
+ if not payload:
54
+ raise ImageError("data URL carried no payload")
55
+ return _from_base64(payload)
56
+ # A path probe over HTTP would let a client read server files, and a UNC path would
57
+ # send the service account's credentials to a host the client names.
58
+ if not allow_paths:
59
+ try:
60
+ return _from_base64(value)
61
+ except ImageError as error:
62
+ raise ImageError(
63
+ f"state.{key} is not a data URL or base64 image. The HTTP service accepts "
64
+ f"only those two forms: {error}"
65
+ ) from error
66
+ try:
67
+ candidate = Path(value)
68
+ path = candidate if candidate.is_file() else None
69
+ except (OSError, ValueError):
70
+ path = None # too long or illegal as a path, so it is not one
71
+ if path is None:
72
+ return _from_base64(value)
73
+ # UnidentifiedImageError subclasses OSError, so reading the file stays outside
74
+ # the probe above, which would report a real file as bad base64.
75
+ try:
76
+ opened = pil_image.open(path).convert("RGB")
77
+ except (OSError, ValueError, pil_image.DecompressionBombError) as error:
78
+ raise ImageError(f"{path} is not a readable image: {error}") from error
79
+ return cast("Image", opened)
80
+
81
+
82
+ def extract_image(state: Any, *, allow_paths: bool = True) -> tuple[Any, Image | None]:
83
+ """Return the state with any image removed, and the image itself.
84
+
85
+ A state that is not a mapping carries no image, so it comes back untouched.
86
+ """
87
+ if not isinstance(state, dict):
88
+ return state, None
89
+
90
+ for key in IMAGE_KEYS:
91
+ value = state.get(key)
92
+ if isinstance(value, str) and value:
93
+ remainder = {k: v for k, v in state.items() if k != key}
94
+ return (remainder or "(see image)"), _decode(value, key, allow_paths=allow_paths)
95
+ return state, None