bynn-decisionnode 0.3.1__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.
decisionnode/_types.py ADDED
@@ -0,0 +1,764 @@
1
+ """What comes back: decisions, answers, the safety check and the model list.
2
+
3
+ Values are the ones the API sent. Numbers are not rounded or renormalised, so a
4
+ probability such as ``0.8200000000000001`` stays as it is, and a list may sum to
5
+ 0.99 or 1.01.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Union
12
+
13
+ from ._boosters import Evidence, parse_evidence
14
+
15
+ __all__ = [
16
+ "Answer",
17
+ "TruthAnswer",
18
+ "ChoiceAnswer",
19
+ "ScoreAnswer",
20
+ "NumberAnswer",
21
+ "PointAnswer",
22
+ "BoxAnswer",
23
+ "UnknownAnswer",
24
+ "AnyAnswer",
25
+ "Cost",
26
+ "Usage",
27
+ "Safety",
28
+ "Decision",
29
+ "Fallback",
30
+ "ModelInfo",
31
+ ]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Answer:
36
+ """Base class of every answer.
37
+
38
+ Attributes:
39
+ type: The answer's type as sent (``truth``, ``choice``, ``score``, ``number``, or a type
40
+ this version of the SDK does not know).
41
+ raw: The answer object exactly as received.
42
+ """
43
+
44
+ type: Optional[str]
45
+ raw: Dict[str, Any] = field(repr=False)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TruthAnswer(Answer):
50
+ """The answer to a ``truth`` question.
51
+
52
+ Attributes:
53
+ truth: The probability that the answer is yes, from 0 to 1.
54
+
55
+ ``type`` is the answer's type as sent; a ``truth`` question may be answered under
56
+ the API's other name for the type, which reads the same here.
57
+ """
58
+
59
+ truth: float
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ChoiceAnswer(Answer):
64
+ """The answer to a ``choice`` question.
65
+
66
+ Attributes:
67
+ choice: The chosen option, by its name exactly as sent.
68
+ confidence: 0 means no better than a uniform guess, 1 means certain.
69
+ probabilities: One entry per option, keyed by the option name exactly as sent.
70
+ """
71
+
72
+ choice: str
73
+ confidence: float
74
+ probabilities: Dict[str, float]
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ScoreAnswer(Answer):
79
+ """The answer to a ``score`` question.
80
+
81
+ Attributes:
82
+ score: The expected level index, from 0 to the number of levels minus 1.
83
+ confidence: How concentrated the probabilities are around the most likely level.
84
+ probabilities: One entry per level, keyed by the level index as an ``int``.
85
+ legend: The level index mapped to the level exactly as sent.
86
+ """
87
+
88
+ score: float
89
+ confidence: float
90
+ probabilities: Dict[int, float]
91
+ legend: Dict[int, Any]
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class NumberAnswer(Answer):
96
+ """The answer to a ``number`` question.
97
+
98
+ Attributes:
99
+ number: The most likely value of the grid, as the API wrote it (an ``int`` for a
100
+ whole value, else a ``float``).
101
+ expected: The probability-weighted mean of the values, to the grid's decimals.
102
+ confidence: How concentrated the probabilities are around the most likely value.
103
+ probabilities: The likely values, keyed by the value exactly as the API wrote it
104
+ (``"7"``, ``"3.5"``, ``"-2.345"``), in ascending order.
105
+ """
106
+
107
+ number: Union[int, float]
108
+ expected: Union[int, float]
109
+ confidence: float
110
+ probabilities: Dict[str, float]
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class PointAnswer(Answer):
115
+ """The answer to a ``point`` question.
116
+
117
+ Attributes:
118
+ x: The spot's distance from the image's left edge.
119
+ y: The spot's distance from the image's top edge. Both are whole pixels, or
120
+ fractions of the width and height when the question asked for
121
+ ``normalized`` units.
122
+ present: The probability that what the instructions describe is in the image at
123
+ all. Under a low ``present`` the spot is only the best guess.
124
+ confidence: The probability that the spot lies on the thing, if it is there.
125
+ image: The id of the image looked in.
126
+ width: That image's width in pixels.
127
+ height: That image's height in pixels.
128
+ """
129
+
130
+ x: Union[int, float]
131
+ y: Union[int, float]
132
+ present: float
133
+ confidence: float
134
+ image: str
135
+ width: int
136
+ height: int
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class BoxAnswer(Answer):
141
+ """The answer to a ``box`` question.
142
+
143
+ Attributes:
144
+ x1: The left edge of the box.
145
+ y1: The top edge.
146
+ x2: The right edge.
147
+ y2: The bottom edge. In the same units as a point.
148
+ present: The probability that what the instructions describe is in the image at
149
+ all.
150
+ confidence: The probability that the box overlaps the true box by at least half
151
+ (intersection over union of 0.5 or more), if the thing is there.
152
+ image: The id of the image looked in.
153
+ width: That image's width in pixels.
154
+ height: That image's height in pixels.
155
+ """
156
+
157
+ x1: Union[int, float]
158
+ y1: Union[int, float]
159
+ x2: Union[int, float]
160
+ y2: Union[int, float]
161
+ present: float
162
+ confidence: float
163
+ image: str
164
+ width: int
165
+ height: int
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class PointPlace:
170
+ """One point of a :class:`PointsAnswer`.
171
+
172
+ Attributes:
173
+ x: The spot's distance from the image's left edge.
174
+ y: Its distance from the image's top edge, in the question's units.
175
+ confidence: The probability that this point lies on one of the things asked for.
176
+ """
177
+
178
+ x: Union[int, float]
179
+ y: Union[int, float]
180
+ confidence: Union[int, float]
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class BoxPlace:
185
+ """One box of a :class:`BoxesAnswer`.
186
+
187
+ Attributes:
188
+ x1: The left edge of the box.
189
+ y1: The top edge.
190
+ x2: The right edge.
191
+ y2: The bottom edge, in the question's units.
192
+ confidence: The probability that this box is right.
193
+ """
194
+
195
+ x1: Union[int, float]
196
+ y1: Union[int, float]
197
+ x2: Union[int, float]
198
+ y2: Union[int, float]
199
+ confidence: Union[int, float]
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class PointsAnswer(Answer):
204
+ """The answer to a ``point`` question asked with ``max``: up to ``max`` points.
205
+
206
+ Attributes:
207
+ count: How many points follow, 0 to ``max``.
208
+ present: The probability that at least one such thing is in the image.
209
+ complete: The probability that nothing was left out, that the true number is
210
+ not above ``max``. Low when the picture holds more than you asked for.
211
+ points: The points, left to right by their centre, ties from the top, each with
212
+ its own ``confidence``; an empty list at 0.
213
+ image: The id of the image looked in.
214
+ width: That image's width in pixels.
215
+ height: That image's height in pixels.
216
+ """
217
+
218
+ count: int
219
+ present: Union[int, float]
220
+ complete: Union[int, float]
221
+ points: List[PointPlace]
222
+ image: str
223
+ width: int
224
+ height: int
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class BoxesAnswer(Answer):
229
+ """The answer to a ``box`` question asked with ``max``: up to ``max`` boxes.
230
+
231
+ Attributes:
232
+ count: How many boxes follow, 0 to ``max``.
233
+ present: The probability that at least one such thing is in the image.
234
+ complete: The probability that nothing was left out, that the true number is
235
+ not above ``max``. Low when the picture holds more than you asked for.
236
+ boxes: The boxes, left to right by their centre, ties from the top, each with its
237
+ own ``confidence``; an empty list at 0.
238
+ image: The id of the image looked in.
239
+ width: That image's width in pixels.
240
+ height: That image's height in pixels.
241
+ """
242
+
243
+ count: int
244
+ present: Union[int, float]
245
+ complete: Union[int, float]
246
+ boxes: List[BoxPlace]
247
+ image: str
248
+ width: int
249
+ height: int
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class UnknownAnswer(Answer):
254
+ """An answer of a type this version of the SDK does not know, or of a known type whose
255
+ fields are missing or of the wrong type. Read its fields from ``raw``."""
256
+
257
+
258
+ AnyAnswer = Union[
259
+ TruthAnswer,
260
+ ChoiceAnswer,
261
+ ScoreAnswer,
262
+ NumberAnswer,
263
+ PointAnswer,
264
+ BoxAnswer,
265
+ PointsAnswer,
266
+ BoxesAnswer,
267
+ UnknownAnswer,
268
+ ]
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class Cost:
273
+ """What an answer cost, in USD, as the server sent it.
274
+
275
+ The amounts are the server's, never recomputed: ``usd`` is not summed from the parts,
276
+ since the server rounds each amount on its own. An amount of the wrong type is ``None``.
277
+
278
+ Attributes:
279
+ usd: The answer's whole cost.
280
+ model_usd: The model's share.
281
+ boosters_usd: The boosters' share.
282
+ free_tier: Whether the answer was drawn from the free tier.
283
+ """
284
+
285
+ usd: Optional[Union[int, float]] = None
286
+ model_usd: Optional[Union[int, float]] = None
287
+ boosters_usd: Optional[Union[int, float]] = None
288
+ free_tier: Optional[bool] = None
289
+
290
+
291
+ def _amount(value: Any) -> Optional[Union[int, float]]:
292
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
293
+
294
+
295
+ def parse_cost(value: Any) -> Optional[Cost]:
296
+ """``usage.cost`` as sent, or ``None`` when the usage has none (never zeros)."""
297
+ if not isinstance(value, Mapping):
298
+ return None
299
+ free = value.get("free_tier")
300
+ return Cost(
301
+ usd=_amount(value.get("usd")),
302
+ model_usd=_amount(value.get("model_usd")),
303
+ boosters_usd=_amount(value.get("boosters_usd")),
304
+ free_tier=free if isinstance(free, bool) else None,
305
+ )
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class Usage:
310
+ """Token counts of one decision; for a request with videos also the frames and the
311
+ seconds the server read, and for a request with boosters the readings' tokens and the
312
+ booster calls billed (``None`` otherwise). ``cost`` is what the answer cost, as sent
313
+ (``None`` when the server sent none)."""
314
+
315
+ input_tokens: Optional[int] = None
316
+ output_tokens: Optional[int] = None
317
+ # Only a request with videos carries these.
318
+ video_frames: Optional[int] = None
319
+ video_seconds: Optional[Union[int, float]] = None
320
+ # Only a request with boosters carries these: the share of input_tokens the readings
321
+ # take, and the booster calls billed.
322
+ booster_tokens: Optional[int] = None
323
+ boosters: Optional[int] = None
324
+ # What the answer cost, as the server sent it.
325
+ cost: Optional[Cost] = None
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class Safety:
330
+ """What the hosted API's safety check found for a request.
331
+
332
+ Attributes:
333
+ checked: Whether the check ran.
334
+ harm: The probability for the ``harm`` category.
335
+ self_harm: The probability for the ``self_harm`` category.
336
+ flagged: The categories above the key's thresholds (a refused category
337
+ included), in the order the server listed them.
338
+ reason: Why the check did not run, when ``checked`` is false.
339
+ categories: Every category the server reported, with its probability.
340
+ """
341
+
342
+ checked: bool
343
+ harm: Optional[float] = None
344
+ self_harm: Optional[float] = None
345
+ flagged: List[str] = field(default_factory=list)
346
+ reason: Optional[str] = None
347
+ categories: Dict[str, Optional[float]] = field(default_factory=dict)
348
+
349
+ def to_dict(self) -> Dict[str, Any]:
350
+ """The result in the shape of the response body's ``safety`` field."""
351
+ if not self.checked:
352
+ return {"checked": False, "reason": self.reason}
353
+ out: Dict[str, Any] = {"checked": True, "harm": self.harm, "self_harm": self.self_harm}
354
+ for name, value in self.categories.items():
355
+ out.setdefault(name, value)
356
+ out["flagged"] = list(self.flagged)
357
+ return out
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class Fallback:
362
+ """A fallback answer: the model asked for had no worker ready at once, so another
363
+ model answered.
364
+
365
+ Attributes:
366
+ served_model: The model that answered, from ``x-decisionnode-fallback``.
367
+ requested_model: The model the request asked for, from
368
+ ``x-decisionnode-requested-model``: the family id for a family id or an
369
+ older id, the pinned id for a pinned id.
370
+ """
371
+
372
+ served_model: str
373
+ requested_model: Optional[str] = None
374
+
375
+
376
+ @dataclass(frozen=True)
377
+ class Decision:
378
+ """The result of one ``decide`` call.
379
+
380
+ Read an answer by its question id: ``decision["route"]`` or ``decision.answers["route"]``.
381
+
382
+ Attributes:
383
+ answers: The answers, by question id.
384
+ model: The model version that answered; for a fine-tuned model its pinned name
385
+ (``decisionnode-moe@acme/fraud:3``).
386
+ usage: Token counts.
387
+ request_id: The ``x-request-id`` response header.
388
+ latency_ms: The server's own time for the request, from ``x-decisionnode-latency-ms``.
389
+ safety: What the safety check found, or ``None`` when the server sent nothing
390
+ (a self-hosted server, for example).
391
+ raw: The parsed response body.
392
+ warnings: What the server says it changed when it read the request's videos (a
393
+ lower rate or resolution), as sent; empty when it sent none.
394
+ evidence: What each booster of the request read: booster name to
395
+ :class:`Evidence`, in the request's order; ``None`` when the request named none.
396
+ fallback: On a fallback answer, the model that answered and the model asked for;
397
+ ``None`` on an ordinary answer. ``model`` is the model that answered.
398
+ """
399
+
400
+ answers: Dict[str, AnyAnswer]
401
+ model: Optional[str]
402
+ usage: Usage
403
+ request_id: Optional[str]
404
+ latency_ms: Optional[Union[int, float]]
405
+ safety: Optional[Safety]
406
+ raw: Dict[str, Any] = field(repr=False)
407
+ warnings: List[str] = field(default_factory=list)
408
+ evidence: Optional[Dict[str, Evidence]] = None
409
+ fallback: Optional[Fallback] = None
410
+
411
+ def __getitem__(self, question_id: str) -> Any:
412
+ # Typed as Any so that ``decision["route"].choice`` reads naturally under a type
413
+ # checker; ``answers`` keeps the precise union for code that narrows it.
414
+ return self.answers[question_id]
415
+
416
+ def __contains__(self, question_id: object) -> bool:
417
+ return question_id in self.answers
418
+
419
+ def __iter__(self) -> Iterator[str]:
420
+ return iter(self.answers)
421
+
422
+ def __len__(self) -> int:
423
+ return len(self.answers)
424
+
425
+
426
+ @dataclass(frozen=True)
427
+ class ModelInfo:
428
+ """One entry of the model list returned by ``models()``.
429
+
430
+ Attributes:
431
+ name: The model id to send, such as ``decisionnode-moe``.
432
+ description: What the model is.
433
+ release_date: ``YYYY-MM-DD``.
434
+ limits: Every other field of the entry: the limits the model publishes, such
435
+ as the largest request in tokens.
436
+ raw: The entry exactly as received.
437
+ fine_tuned: ``True`` for a fine-tuned model of the key's workspace; ``None`` on
438
+ the catalogue's own entries.
439
+ base: A fine-tuned model's base model.
440
+ adapter_version: A fine-tuned model's version number; on its plain name, the
441
+ version the name resolves to.
442
+ status: A fine-tuned model's status: ``deployed`` (answers decisions) or ``ready``
443
+ (trained, not deployed); any other value as received.
444
+ """
445
+
446
+ name: str
447
+ description: Optional[str]
448
+ release_date: Optional[str]
449
+ limits: Dict[str, Any]
450
+ raw: Dict[str, Any] = field(repr=False)
451
+ fine_tuned: Optional[bool] = None
452
+ base: Optional[str] = None
453
+ adapter_version: Optional[int] = None
454
+ status: Optional[str] = None
455
+
456
+
457
+ # --------------------------------------------------------------------------- parsing
458
+
459
+ # The API's older wire name for the truth type, still accepted for a question sent raw. Its answers read as TruthAnswer.
460
+ LEGACY_TRUTH = "noul"
461
+
462
+
463
+ class _Malformed(Exception):
464
+ """A known answer type whose fields are missing or of the wrong type."""
465
+
466
+
467
+ def _number(raw: Mapping[str, Any], key: str) -> Union[int, float]:
468
+ value = raw.get(key)
469
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
470
+ raise _Malformed(key)
471
+ return value
472
+
473
+
474
+ def _string(raw: Mapping[str, Any], key: str) -> str:
475
+ value = raw.get(key)
476
+ if not isinstance(value, str):
477
+ raise _Malformed(key)
478
+ return value
479
+
480
+
481
+ def _places(raw: Mapping[str, Any], key: str, fields: Sequence[str]) -> List[Dict[str, Union[int, float]]]:
482
+ """The list of a several-places answer: each entry an object of numbers."""
483
+ value = raw.get(key)
484
+ if not isinstance(value, list):
485
+ raise _Malformed(key)
486
+ out = []
487
+ for entry in value:
488
+ if not isinstance(entry, Mapping):
489
+ raise _Malformed(key)
490
+ out.append({name: _number(entry, name) for name in fields})
491
+ return out
492
+
493
+
494
+ def _count(raw: Mapping[str, Any], key: str) -> int:
495
+ value = raw.get(key)
496
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
497
+ raise _Malformed(key)
498
+ return value
499
+
500
+
501
+ def _mapping(raw: Mapping[str, Any], key: str) -> Mapping[str, Any]:
502
+ value = raw.get(key)
503
+ if not isinstance(value, Mapping):
504
+ raise _Malformed(key)
505
+ return value
506
+
507
+
508
+ def _number_values(values: Mapping[str, Any]) -> Dict[str, Union[int, float]]:
509
+ out: Dict[str, Union[int, float]] = {}
510
+ for name, value in values.items():
511
+ if not isinstance(name, str) or isinstance(value, bool) or not isinstance(value, (int, float)):
512
+ raise _Malformed(name)
513
+ out[name] = value
514
+ return out
515
+
516
+
517
+ def _index_keys(values: Mapping[str, Any]) -> Dict[int, Any]:
518
+ # Level indexes are sent as the strings "0" to "K-1".
519
+ out: Dict[int, Any] = {}
520
+ for key, value in values.items():
521
+ if not isinstance(key, str) or not (key == "0" or (key.isdigit() and key.isascii() and key[0] != "0")):
522
+ raise _Malformed(key)
523
+ out[int(key)] = value
524
+ return out
525
+
526
+
527
+ def parse_answer(raw: Any) -> AnyAnswer:
528
+ """One answer. A known type with a missing or wrongly typed field is returned as an
529
+ :class:`UnknownAnswer` holding its raw fields, so one odd answer does not fail the decision."""
530
+ if not isinstance(raw, Mapping):
531
+ return UnknownAnswer(type=None, raw={"value": raw})
532
+ raw = dict(raw)
533
+ kind = raw.get("type")
534
+ try:
535
+ if kind == "truth":
536
+ return TruthAnswer(type=kind, raw=raw, truth=_number(raw, "truth"))
537
+ if kind == LEGACY_TRUTH:
538
+ return TruthAnswer(type=kind, raw=raw, truth=_number(raw, LEGACY_TRUTH))
539
+ if kind == "number":
540
+ return NumberAnswer(
541
+ type=kind,
542
+ raw=raw,
543
+ number=_number(raw, "number"),
544
+ expected=_number(raw, "expected"),
545
+ confidence=_number(raw, "confidence"),
546
+ probabilities=_number_values(_mapping(raw, "probabilities")),
547
+ )
548
+ if kind == "point" and ("points" in raw or "boxes" in raw):
549
+ # Several places: told by the answer's own list, not by the request.
550
+ return PointsAnswer(
551
+ type=kind,
552
+ raw=raw,
553
+ count=_count(raw, "count"),
554
+ present=_number(raw, "present"),
555
+ complete=_number(raw, "complete"),
556
+ points=[PointPlace(**p) for p in _places(raw, "points", ("x", "y", "confidence"))],
557
+ image=_string(raw, "image"),
558
+ width=_number(raw, "width"), # type: ignore[arg-type]
559
+ height=_number(raw, "height"), # type: ignore[arg-type]
560
+ )
561
+ if kind == "box" and ("points" in raw or "boxes" in raw):
562
+ return BoxesAnswer(
563
+ type=kind,
564
+ raw=raw,
565
+ count=_count(raw, "count"),
566
+ present=_number(raw, "present"),
567
+ complete=_number(raw, "complete"),
568
+ boxes=[BoxPlace(**b) for b in _places(raw, "boxes", ("x1", "y1", "x2", "y2", "confidence"))],
569
+ image=_string(raw, "image"),
570
+ width=_number(raw, "width"), # type: ignore[arg-type]
571
+ height=_number(raw, "height"), # type: ignore[arg-type]
572
+ )
573
+ if kind == "point":
574
+ return PointAnswer(
575
+ type=kind,
576
+ raw=raw,
577
+ x=_number(raw, "x"),
578
+ y=_number(raw, "y"),
579
+ present=_number(raw, "present"),
580
+ confidence=_number(raw, "confidence"),
581
+ image=_string(raw, "image"),
582
+ width=_number(raw, "width"), # type: ignore[arg-type]
583
+ height=_number(raw, "height"), # type: ignore[arg-type]
584
+ )
585
+ if kind == "box":
586
+ return BoxAnswer(
587
+ type=kind,
588
+ raw=raw,
589
+ x1=_number(raw, "x1"),
590
+ y1=_number(raw, "y1"),
591
+ x2=_number(raw, "x2"),
592
+ y2=_number(raw, "y2"),
593
+ present=_number(raw, "present"),
594
+ confidence=_number(raw, "confidence"),
595
+ image=_string(raw, "image"),
596
+ width=_number(raw, "width"), # type: ignore[arg-type]
597
+ height=_number(raw, "height"), # type: ignore[arg-type]
598
+ )
599
+ if kind == "choice":
600
+ return ChoiceAnswer(
601
+ type=kind,
602
+ raw=raw,
603
+ choice=_string(raw, "choice"),
604
+ confidence=_number(raw, "confidence"),
605
+ probabilities=_number_values(_mapping(raw, "probabilities")),
606
+ )
607
+ if kind == "score":
608
+ probabilities = _index_keys(_mapping(raw, "probabilities"))
609
+ for value in probabilities.values():
610
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
611
+ raise _Malformed("probabilities")
612
+ return ScoreAnswer(
613
+ type=kind,
614
+ raw=raw,
615
+ score=_number(raw, "score"),
616
+ confidence=_number(raw, "confidence"),
617
+ probabilities=probabilities,
618
+ legend=_index_keys(_mapping(raw, "legend")),
619
+ )
620
+ except _Malformed:
621
+ pass
622
+ return UnknownAnswer(type=kind if isinstance(kind, str) else None, raw=raw)
623
+
624
+
625
+ def parse_decision(body: Any, headers: Mapping[str, str]) -> Decision:
626
+ if not isinstance(body, Mapping):
627
+ body = {}
628
+ answers = body.get("answers")
629
+ usage = body.get("usage") if isinstance(body.get("usage"), Mapping) else {}
630
+ warnings = body.get("warnings")
631
+ return Decision(
632
+ answers={str(k): parse_answer(v) for k, v in answers.items()} if isinstance(answers, Mapping) else {},
633
+ model=body.get("model"),
634
+ usage=Usage(
635
+ input_tokens=usage.get("input_tokens"),
636
+ output_tokens=usage.get("output_tokens"),
637
+ video_frames=usage.get("video_frames"),
638
+ video_seconds=usage.get("video_seconds"),
639
+ booster_tokens=usage.get("booster_tokens"),
640
+ boosters=usage.get("boosters"),
641
+ cost=parse_cost(usage.get("cost")),
642
+ ),
643
+ request_id=headers.get("x-request-id"),
644
+ latency_ms=parse_latency(headers.get("x-decisionnode-latency-ms")),
645
+ safety=parse_safety(headers.get("x-decisionnode-safety")),
646
+ raw=dict(body),
647
+ warnings=[w for w in warnings if isinstance(w, str)] if isinstance(warnings, list) else [],
648
+ evidence=parse_evidence(body.get("evidence")),
649
+ fallback=parse_fallback(headers),
650
+ )
651
+
652
+
653
+ def parse_fallback(headers: Mapping[str, str]) -> Optional[Fallback]:
654
+ """The fallback headers of an answer, or ``None`` on an ordinary answer."""
655
+ served = headers.get("x-decisionnode-fallback")
656
+ if served is None:
657
+ return None
658
+ return Fallback(served_model=served, requested_model=headers.get("x-decisionnode-requested-model"))
659
+
660
+
661
+ def parse_latency(value: Optional[str]) -> Optional[Union[int, float]]:
662
+ if value is None:
663
+ return None
664
+ try:
665
+ number = float(value.strip())
666
+ except ValueError:
667
+ return None
668
+ if number != number or number in (float("inf"), float("-inf")):
669
+ return None
670
+ return int(number) if number.is_integer() else number
671
+
672
+
673
+ def parse_model(raw: Any) -> ModelInfo:
674
+ entry = dict(raw) if isinstance(raw, Mapping) else {}
675
+ fine_tuned, base, version, status = (entry.get(k) for k in ("fine_tuned", "base", "adapter_version", "status"))
676
+ return ModelInfo(
677
+ name=entry.get("name", ""),
678
+ description=entry.get("description"),
679
+ release_date=entry.get("release_date"),
680
+ limits={k: v for k, v in entry.items() if k not in ("name", "description", "release_date")},
681
+ raw=entry,
682
+ fine_tuned=fine_tuned if isinstance(fine_tuned, bool) else None,
683
+ base=base if isinstance(base, str) else None,
684
+ adapter_version=version if isinstance(version, int) and not isinstance(version, bool) else None,
685
+ status=status if isinstance(status, str) else None,
686
+ )
687
+
688
+
689
+ def _split_outside_quotes(text: str, sep: str) -> List[str]:
690
+ parts, current, quoted, escaped = [], [], False, False
691
+ for ch in text:
692
+ if escaped:
693
+ current.append(ch)
694
+ escaped = False
695
+ elif ch == "\\" and quoted:
696
+ current.append(ch)
697
+ escaped = True
698
+ elif ch == '"':
699
+ current.append(ch)
700
+ quoted = not quoted
701
+ elif ch == sep and not quoted:
702
+ parts.append("".join(current))
703
+ current = []
704
+ else:
705
+ current.append(ch)
706
+ parts.append("".join(current))
707
+ return parts
708
+
709
+
710
+ def _unquote(value: str) -> str:
711
+ value = value.strip()
712
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
713
+ inner = value[1:-1]
714
+ out, escaped = [], False
715
+ for ch in inner:
716
+ if escaped:
717
+ out.append(ch)
718
+ escaped = False
719
+ elif ch == "\\":
720
+ escaped = True
721
+ else:
722
+ out.append(ch)
723
+ return "".join(out)
724
+ return value
725
+
726
+
727
+ def parse_safety(value: Optional[str]) -> Optional[Safety]:
728
+ """Parse the ``x-decisionnode-safety`` header.
729
+
730
+ The header is a list of ``name=value`` members with optional ``;parameters``:
731
+ ``harm=0.031, self_harm=0.004``, ``harm=0.912;flagged, self_harm=0.004``, or
732
+ ``unchecked;reason="no-model-question"`` when the check did not run.
733
+ """
734
+ if value is None:
735
+ return None
736
+ value = value.strip()
737
+ if not value:
738
+ return None
739
+ members = [m.strip() for m in _split_outside_quotes(value, ",") if m.strip()]
740
+ categories: Dict[str, Optional[float]] = {}
741
+ flagged: List[str] = []
742
+ for member in members:
743
+ head, *params = [p.strip() for p in _split_outside_quotes(member, ";")]
744
+ name, has_value, number = head.partition("=")
745
+ name = name.strip()
746
+ param_map: Dict[str, str] = {}
747
+ for param in params:
748
+ key, _, pvalue = param.partition("=")
749
+ param_map[key.strip()] = _unquote(pvalue) if pvalue else "?1"
750
+ if name == "unchecked" and not has_value:
751
+ return Safety(checked=False, reason=param_map.get("reason"))
752
+ try:
753
+ categories[name] = float(number) if has_value else None
754
+ except ValueError:
755
+ categories[name] = None
756
+ if any(param_map.get(flag, "?0") != "?0" for flag in ("flagged", "blocked")):
757
+ flagged.append(name)
758
+ return Safety(
759
+ checked=True,
760
+ harm=categories.get("harm"),
761
+ self_harm=categories.get("self_harm"),
762
+ flagged=flagged,
763
+ categories=categories,
764
+ )