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/_base.py ADDED
@@ -0,0 +1,548 @@
1
+ """What the sync and async clients share: settings, request bodies, retries and errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import email.utils
7
+ import json
8
+ import logging
9
+ import math
10
+ import os
11
+ import random
12
+ import time
13
+ from http.cookiejar import CookieJar, DefaultCookiePolicy
14
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Union
15
+ from urllib.parse import urlsplit
16
+
17
+ import httpx
18
+
19
+ from ._errors import (
20
+ AuthenticationError,
21
+ BadRequestError,
22
+ ContextLimitError,
23
+ DecisionNodeError,
24
+ InsufficientCreditError,
25
+ NotFoundError,
26
+ OverloadedError,
27
+ PermissionDeniedError,
28
+ RateLimitError,
29
+ RefusalError,
30
+ RequestTooLargeError,
31
+ ServerError,
32
+ SessionLimitError,
33
+ ValidationError,
34
+ )
35
+ from ._images import DEFAULT_INLINE_THRESHOLD, Image
36
+ from ._questions import Question, choice, score, truth
37
+ from ._types import parse_safety
38
+ from ._version import __version__
39
+
40
+ logger = logging.getLogger("decisionnode")
41
+
42
+ DEFAULT_BASE_URL = "https://api.decisionnode.com"
43
+ DEFAULT_MODEL = "decisionnode-moe"
44
+ DEFAULT_TIMEOUT = 10.0
45
+ # One attempt of a decision whose boosters name ocr, when the caller set no timeout: the
46
+ # booster reads the text before the model answers.
47
+ OCR_TIMEOUT = 30.0
48
+ # One storage PUT attempt of an image, apart from the API attempt's timeout: up to 10 MB
49
+ # on a slower uplink than the API's answers need.
50
+ DEFAULT_UPLOAD_TIMEOUT = 60.0
51
+ DEFAULT_MAX_RETRIES = 2
52
+ DEFAULT_CONCURRENCY = 8
53
+ USER_AGENT = "decisionnode-python/%s" % __version__
54
+
55
+ RETRY_STATUSES = frozenset({408, 429} | set(range(500, 600)))
56
+ INITIAL_BACKOFF = 0.5
57
+ MAX_BACKOFF = 8.0
58
+ # A wait the server asks for is honoured up to this many seconds. The API asks for at
59
+ # most 30; the bound keeps a misbehaving proxy from stalling a caller for hours.
60
+ MAX_RETRY_AFTER = 60.0
61
+
62
+ DECIDE_PATH = "/v1/decide"
63
+ MODELS_PATH = "/v1/models"
64
+ UPLOADS_PATH = "/v1/uploads"
65
+ SESSIONS_PATH = "/v1/sessions"
66
+ BATCHES_PATH = "/v1/batches"
67
+ COLLECTIONS_PATH = "/v1/collections"
68
+
69
+ # Retry policies. DEFAULT: 408, 429, 5xx, timeouts and dropped connections. MUTATION
70
+ # (batch create, add and finalize): only 429 and 529, which the API answers before
71
+ # applying anything; never after a timeout, a lost answer or another 5xx, since a repeat
72
+ # could create a second draft. SESSION_OPEN: as DEFAULT, except that a 429
73
+ # session_limit_exceeded is never retried. OCR (a decision whose boosters name ocr): as
74
+ # DEFAULT, except that a timeout is never retried, since the server finishes that attempt
75
+ # and may bill it. IDEMPOTENT (collections create and enroll, which carry their own
76
+ # Idempotency-Key, the same on every attempt): as DEFAULT, and also a 409
77
+ # idempotency_in_progress, after its Retry-After; a 409 idempotency_key_reused is raised.
78
+ DEFAULT = "default"
79
+ MUTATION = "mutation"
80
+ SESSION_OPEN = "session_open"
81
+ OCR = "ocr"
82
+ IDEMPOTENT = "idempotent"
83
+ IDEMPOTENCY_IN_PROGRESS = "idempotency_in_progress"
84
+
85
+ # A 400 for an upload the client placed in the request: it uploads again and sends the
86
+ # request once more.
87
+ STALE_UPLOAD = frozenset({"upload_expired", "upload_consumed", "upload_not_found", "upload_incomplete"})
88
+
89
+ ImageInput = Union[Image, Mapping[str, Any]]
90
+
91
+
92
+ class _Unset:
93
+ def __repr__(self) -> str:
94
+ return "<unset>"
95
+
96
+
97
+ UNSET: Any = _Unset()
98
+
99
+
100
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
101
+
102
+
103
+ def check_base_url(url: str) -> str:
104
+ """The base URL without a trailing slash, or ``ValueError`` when it is not usable.
105
+
106
+ ``https`` only, so the key never travels unencrypted. Plain ``http`` is accepted for
107
+ ``localhost``, ``127.0.0.1`` and ``::1`` alone, for a server on the same machine.
108
+ """
109
+ try:
110
+ parts = urlsplit(url)
111
+ host = parts.hostname
112
+ parts.port # noqa: B018 - raises ValueError on a port that is not a number
113
+ except ValueError as exc:
114
+ raise ValueError("base_url %r is not a valid URL: %s" % (url, exc)) from None
115
+ scheme = parts.scheme.lower()
116
+ if not host:
117
+ raise ValueError("base_url %r has no host" % url)
118
+ if scheme == "https":
119
+ pass
120
+ elif scheme == "http":
121
+ if host.lower() not in LOOPBACK_HOSTS:
122
+ raise ValueError(
123
+ "base_url %r uses plain http; use https (http is accepted only for localhost, 127.0.0.1 and ::1)" % url
124
+ )
125
+ else:
126
+ raise ValueError("base_url %r must start with https://" % url)
127
+ if parts.query or parts.fragment:
128
+ raise ValueError("base_url %r must not have a query or a fragment" % url)
129
+ return url.rstrip("/")
130
+
131
+
132
+ def check_timeout(timeout: Any, default: float = DEFAULT_TIMEOUT, name: str = "timeout") -> float:
133
+ if timeout is None:
134
+ return default
135
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
136
+ raise ValueError("%s must be a number of seconds above 0, got %r" % (name, timeout))
137
+ value = float(timeout)
138
+ if not math.isfinite(value) or value <= 0:
139
+ raise ValueError("%s must be a number of seconds above 0, got %r" % (name, timeout))
140
+ return value
141
+
142
+
143
+ def check_max_retries(max_retries: Any) -> int:
144
+ if max_retries is None:
145
+ return DEFAULT_MAX_RETRIES
146
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
147
+ raise ValueError("max_retries must be a whole number, 0 or more, got %r" % (max_retries,))
148
+ return int(max_retries)
149
+
150
+
151
+ def check_inline_threshold(value: Any) -> int:
152
+ if value is None:
153
+ return DEFAULT_INLINE_THRESHOLD
154
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
155
+ raise ValueError("inline_threshold must be a whole number of bytes, 0 or more, got %r" % (value,))
156
+ return int(value)
157
+
158
+
159
+ class Settings:
160
+ """The resolved client settings: an explicit argument, then the environment, then the default.
161
+
162
+ An empty string counts as not given, so ``api_key=""`` falls back to the environment.
163
+ Every setting is checked here, so a bad one fails before any request is sent.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ api_key: Optional[str],
169
+ base_url: Optional[str],
170
+ model: Optional[str],
171
+ timeout: Optional[float],
172
+ max_retries: Optional[int],
173
+ headers: Optional[Mapping[str, str]],
174
+ inline_threshold: Optional[int] = None,
175
+ upload_timeout: Optional[float] = None,
176
+ ) -> None:
177
+ self.api_key = api_key or os.environ.get("DECISIONNODE_API_KEY") or None
178
+ url = base_url or os.environ.get("DECISIONNODE_BASE_URL") or DEFAULT_BASE_URL
179
+ self.base_url = check_base_url(str(url))
180
+ self.model = model or os.environ.get("DECISIONNODE_MODEL") or DEFAULT_MODEL
181
+ # The limit for one whole attempt, from sending the request to the last byte of
182
+ # the response body. Each network step is also bounded by it. Whether the caller
183
+ # set it: a decision naming ocr waits OCR_TIMEOUT unless a timeout was set.
184
+ self.timeout = check_timeout(timeout)
185
+ self.timeout_given = timeout is not None
186
+ self.max_retries = check_max_retries(max_retries)
187
+ self.custom_headers: Dict[str, str] = dict(headers or {})
188
+ self.inline_threshold = check_inline_threshold(inline_threshold)
189
+ self.upload_timeout = check_timeout(upload_timeout, DEFAULT_UPLOAD_TIMEOUT, "upload_timeout")
190
+
191
+ def request_headers(self, has_body: bool, content_type: str = "application/json") -> Dict[str, str]:
192
+ headers = {
193
+ "Authorization": "Bearer %s" % self.require_key(),
194
+ "User-Agent": USER_AGENT,
195
+ "Accept": "application/json",
196
+ }
197
+ if has_body:
198
+ headers["Content-Type"] = content_type
199
+ headers.update(self.custom_headers)
200
+ return headers
201
+
202
+ def require_key(self) -> str:
203
+ if not self.api_key:
204
+ raise AuthenticationError("no API key: pass api_key to the client or set DECISIONNODE_API_KEY")
205
+ return self.api_key
206
+
207
+ def stream_headers(self) -> Dict[str, str]:
208
+ """The headers of a session's WebSocket handshake: the key, never in the URL."""
209
+ headers = {"Authorization": "Bearer %s" % self.require_key()}
210
+ headers.update(self.custom_headers)
211
+ return headers
212
+
213
+ def url(self, path: str) -> str:
214
+ return self.base_url + path
215
+
216
+ def __repr__(self) -> str:
217
+ key = "set" if self.api_key else "missing"
218
+ return "base_url=%r, model=%r, api_key=%s" % (self.base_url, self.model, key)
219
+
220
+
221
+ def no_cookies() -> CookieJar:
222
+ """A cookie jar that keeps nothing, so no request carries state from an earlier response."""
223
+ return CookieJar(policy=DefaultCookiePolicy(allowed_domains=[]))
224
+
225
+
226
+ # --------------------------------------------------------------------------- request bodies
227
+
228
+
229
+ def encode(body: Any) -> bytes:
230
+ try:
231
+ return json.dumps(body, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
232
+ except (TypeError, ValueError) as exc:
233
+ raise ValueError("the request cannot be sent as JSON: %s" % exc) from exc
234
+
235
+
236
+ def check_question_map(questions: Any) -> Dict[str, Any]:
237
+ """The question map with string ids, or ``ValueError`` for an argument that is not one."""
238
+ if questions is None or not isinstance(questions, Mapping):
239
+ raise ValueError("questions must be a mapping of question id to question")
240
+ if len(questions) == 0:
241
+ raise ValueError("questions must hold at least one question")
242
+ # Each question is checked as the API checks it (a question that is not an object is
243
+ # its 422), so it passes through as given here.
244
+ return {str(qid): q for qid, q in questions.items()}
245
+
246
+
247
+ def image_list(images: Any) -> List[Any]:
248
+ """The images argument as a list, or ``ValueError``."""
249
+ if not images:
250
+ return []
251
+ if isinstance(images, (Image, Mapping)):
252
+ return [images]
253
+ if isinstance(images, (str, bytes)):
254
+ raise ValueError("images must be a list of Image objects or wire-form mappings")
255
+ out = list(images)
256
+ for img in out:
257
+ if not isinstance(img, (Image, Mapping)):
258
+ raise ValueError("images must be Image objects or wire-form mappings, got %r" % (img,))
259
+ return out
260
+
261
+
262
+ def decide_body(
263
+ state: Any,
264
+ questions: Mapping[str, Any],
265
+ wires: Sequence[Mapping[str, Any]],
266
+ model: Optional[str],
267
+ default_model: str,
268
+ videos: Sequence[Mapping[str, Any]] = (),
269
+ boosters: Any = None,
270
+ fallback: Optional[bool] = None,
271
+ ) -> Dict[str, Any]:
272
+ """The decide body. ``boosters`` is the field as sent (``booster_wire``) and
273
+ ``fallback`` the option as given; ``None`` leaves either key out, so a request
274
+ without them is unchanged."""
275
+ body: Dict[str, Any] = {
276
+ "model": model or default_model,
277
+ "state": "" if state is None or state is UNSET else state,
278
+ "questions": dict(questions),
279
+ }
280
+ if wires:
281
+ body["images"] = list(wires)
282
+ if videos:
283
+ body["videos"] = list(videos)
284
+ if boosters is not None:
285
+ body["boosters"] = boosters
286
+ if fallback is not None:
287
+ body["fallback"] = fallback
288
+ return body
289
+
290
+
291
+ DECIDE_KEYS = frozenset({"state", "questions", "images", "videos", "model", "boosters", "fallback"})
292
+
293
+
294
+ def check_fallback(fallback: Any) -> Optional[bool]:
295
+ """The ``fallback`` option: ``True``, ``False`` or ``None`` (left out)."""
296
+ if fallback is not None and not isinstance(fallback, bool):
297
+ raise ValueError("fallback must be True or False, got %r" % (fallback,))
298
+ return fallback
299
+
300
+
301
+ def should_retry(policy: str, status: int, error: DecisionNodeError) -> bool:
302
+ if policy == MUTATION:
303
+ return status in (429, 529)
304
+ if policy == SESSION_OPEN and isinstance(error, SessionLimitError):
305
+ return False
306
+ if policy == IDEMPOTENT and status == 409:
307
+ return error.error_type == IDEMPOTENCY_IN_PROGRESS
308
+ return status in RETRY_STATUSES
309
+
310
+
311
+ def parse_time(text: Any) -> Optional[float]:
312
+ """An ISO 8601 UTC time such as ``2026-10-06T09:35:00Z`` as a Unix time, or ``None``."""
313
+ if not isinstance(text, str) or not text:
314
+ return None
315
+ value = text.strip()
316
+ if value.endswith(("Z", "z")):
317
+ value = value[:-1] + "+00:00"
318
+ try:
319
+ when = datetime.datetime.fromisoformat(value)
320
+ except ValueError:
321
+ return None
322
+ if when.tzinfo is None:
323
+ when = when.replace(tzinfo=datetime.timezone.utc)
324
+ return when.timestamp()
325
+
326
+
327
+ def check_many(requests: Iterable[Mapping[str, Any]], concurrency: int) -> List[Mapping[str, Any]]:
328
+ if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency < 1:
329
+ raise ValueError("concurrency must be a whole number, 1 or more, got %r" % (concurrency,))
330
+ items = list(requests)
331
+ for i, req in enumerate(items):
332
+ if not isinstance(req, Mapping):
333
+ raise ValueError("decide_many: request %d is not a mapping of decide() arguments" % i)
334
+ unknown = set(req) - DECIDE_KEYS
335
+ if unknown:
336
+ raise ValueError("decide_many: request %d has unknown keys %s" % (i, ", ".join(sorted(map(str, unknown)))))
337
+ return items
338
+
339
+
340
+ def gate_state(action: Any, context: Any) -> Dict[str, Any]:
341
+ state: Dict[str, Any] = {"action": action}
342
+ if context is not None:
343
+ state["context"] = context
344
+ return state
345
+
346
+
347
+ def gate_questions() -> Dict[str, Question]:
348
+ # The same wording in every official SDK, so a gate asks the same thing in any language.
349
+ return {
350
+ "risk": score(
351
+ "How much harm could this action cause if it is wrong?",
352
+ ["none", "low", "moderate", "high", "severe"],
353
+ ),
354
+ "touches_production": truth(
355
+ "Does this action change a production system, production data or anything customers use?"
356
+ ),
357
+ "verdict": choice(
358
+ "Should this action run as it is, wait for a person to confirm it, or be blocked?",
359
+ {
360
+ "allow": "safe to run without asking",
361
+ "confirm": "a person should confirm it first",
362
+ "block": "should not run",
363
+ },
364
+ ),
365
+ }
366
+
367
+
368
+ # --------------------------------------------------------------------------- retries
369
+
370
+
371
+ def retry_after_seconds(headers: Mapping[str, str]) -> Optional[float]:
372
+ """The wait the server asked for, in seconds, capped at 60.
373
+
374
+ ``retry-after-ms`` first, then ``Retry-After`` as seconds or as an HTTP date.
375
+ """
376
+ wait = requested_wait(headers)
377
+ return None if wait is None else min(wait, MAX_RETRY_AFTER)
378
+
379
+
380
+ def requested_wait(headers: Mapping[str, str]) -> Optional[float]:
381
+ ms = headers.get("retry-after-ms")
382
+ if ms is not None:
383
+ try:
384
+ value = float(ms) / 1000.0
385
+ if math.isfinite(value) and value >= 0:
386
+ return value
387
+ except ValueError:
388
+ pass
389
+ text = headers.get("retry-after")
390
+ if text is None:
391
+ return None
392
+ try:
393
+ value = float(text)
394
+ except ValueError:
395
+ try:
396
+ when = email.utils.parsedate_to_datetime(text)
397
+ except (TypeError, ValueError, IndexError):
398
+ return None
399
+ if when is None:
400
+ return None
401
+ if when.tzinfo is None:
402
+ when = when.replace(tzinfo=datetime.timezone.utc)
403
+ return max(0.0, when.timestamp() - time.time())
404
+ if math.isfinite(value) and value >= 0:
405
+ return value
406
+ return None
407
+
408
+
409
+ def backoff(attempt: int) -> float:
410
+ """Exponential backoff from 0.5 s with jitter, capped at 8 s. ``attempt`` counts from 0."""
411
+ base = min(MAX_BACKOFF, INITIAL_BACKOFF * float(2**attempt))
412
+ return base * (0.75 + random.random() * 0.25)
413
+
414
+
415
+ def retry_wait(attempt: int, headers: Optional[Mapping[str, str]]) -> float:
416
+ wait = retry_after_seconds(headers) if headers is not None else None
417
+ return backoff(attempt) if wait is None else wait
418
+
419
+
420
+ def lower_headers(headers: httpx.Headers) -> Dict[str, str]:
421
+ return {k.lower(): v for k, v in headers.items()}
422
+
423
+
424
+ # --------------------------------------------------------------------------- errors
425
+
426
+
427
+ # The status text of an error whose body is not JSON: the same in every DecisionNode
428
+ # SDK, never the HTTP library's own wording.
429
+ _STATUS_TEXT = {
430
+ 400: "Bad Request",
431
+ 401: "Unauthorized",
432
+ 402: "Payment Required",
433
+ 403: "Forbidden",
434
+ 404: "Not Found",
435
+ 405: "Method Not Allowed",
436
+ 408: "Request Timeout",
437
+ 409: "Conflict",
438
+ 410: "Gone",
439
+ 413: "Content Too Large",
440
+ 422: "Unprocessable Content",
441
+ 429: "Too Many Requests",
442
+ 500: "Internal Server Error",
443
+ 502: "Bad Gateway",
444
+ 503: "Service Unavailable",
445
+ 504: "Gateway Timeout",
446
+ 529: "Overloaded",
447
+ }
448
+
449
+
450
+ def status_message(status: int, request_id: Optional[str]) -> str:
451
+ """The client's own message for an error whose body is not JSON (an edge's or a
452
+ proxy's page, or no body): ``HTTP <status> <text>``, then the request id when sent."""
453
+ out = "HTTP %d" % status
454
+ if status in _STATUS_TEXT:
455
+ out += " " + _STATUS_TEXT[status]
456
+ if request_id is not None:
457
+ out += " (request id %s)" % request_id
458
+ return out
459
+
460
+
461
+ def make_error(status: int, headers: Mapping[str, str], text: str) -> DecisionNodeError:
462
+ body: Any = None
463
+ message: Optional[str] = None
464
+ try:
465
+ body = json.loads(text)
466
+ except ValueError:
467
+ # Not the JSON envelope (an HTML or text page, or an empty body): the class
468
+ # follows the status alone, and the page is never read into any field.
469
+ message = status_message(status, headers.get("x-request-id"))
470
+ detail = body.get("detail") if isinstance(body, Mapping) else None
471
+ error_type: Optional[str] = None
472
+ category: Optional[str] = None
473
+ entries: Optional[List[Any]] = None
474
+ if isinstance(detail, Mapping):
475
+ error_type = detail.get("error_type")
476
+ message = detail.get("message")
477
+ category = detail.get("category")
478
+ elif isinstance(detail, str):
479
+ message = detail
480
+ elif isinstance(detail, list):
481
+ entries = detail
482
+ kwargs: Dict[str, Any] = dict(
483
+ status=status,
484
+ error_type=error_type,
485
+ request_id=headers.get("x-request-id"),
486
+ reason=headers.get("x-decisionnode-error"),
487
+ body=body,
488
+ headers=headers,
489
+ )
490
+ if status == 400:
491
+ if error_type == "max_tokens_exceeded":
492
+ return ContextLimitError(message, **kwargs)
493
+ return BadRequestError(message, **kwargs)
494
+ if status == 401:
495
+ return AuthenticationError(message, **kwargs)
496
+ if status == 402:
497
+ return InsufficientCreditError(message, **kwargs)
498
+ if status == 403:
499
+ if error_type == "refusal_error":
500
+ return RefusalError(
501
+ message, category=category, safety=parse_safety(headers.get("x-decisionnode-safety")), **kwargs
502
+ )
503
+ return PermissionDeniedError(message, **kwargs)
504
+ if status == 404:
505
+ return NotFoundError(message, **kwargs)
506
+ if status == 413:
507
+ return RequestTooLargeError(message, **kwargs)
508
+ if status == 422:
509
+ return ValidationError(message, entries=entries, **kwargs)
510
+ if status == 429:
511
+ if error_type == "session_limit_exceeded":
512
+ return SessionLimitError(message, retry_after=retry_after_seconds(headers), **kwargs)
513
+ return RateLimitError(message, retry_after=retry_after_seconds(headers), **kwargs)
514
+ if status == 529:
515
+ return OverloadedError(message, retry_after=retry_after_seconds(headers), **kwargs)
516
+ if 500 <= status <= 599:
517
+ return ServerError(message, **kwargs)
518
+ # Redirects (never followed) and every status without a class of its own.
519
+ return DecisionNodeError(message, **kwargs)
520
+
521
+
522
+ def parse_json(status: int, headers: Mapping[str, str], text: str) -> Any:
523
+ """The body of a 2xx response. A body that is not JSON is a :class:`ServerError`."""
524
+ try:
525
+ return json.loads(text)
526
+ except ValueError as exc:
527
+ raise ServerError(
528
+ "the response body is not JSON",
529
+ status=status,
530
+ request_id=headers.get("x-request-id"),
531
+ body=text,
532
+ headers=headers,
533
+ ) from exc
534
+
535
+
536
+ def log_response(
537
+ method: str, path: str, status: Union[int, str], request_id: Optional[str], started: float, attempt: int
538
+ ) -> None:
539
+ if logger.isEnabledFor(logging.DEBUG):
540
+ logger.debug(
541
+ "%s %s -> %s request_id=%s %.0fms attempt=%d",
542
+ method,
543
+ path,
544
+ status,
545
+ request_id or "-",
546
+ (time.monotonic() - started) * 1000,
547
+ attempt + 1,
548
+ )