beakr-sdk 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.
Files changed (60) hide show
  1. beakr_sdk/__init__.py +71 -0
  2. beakr_sdk/_base_client.py +458 -0
  3. beakr_sdk/_calls.py +225 -0
  4. beakr_sdk/_codegen.py +672 -0
  5. beakr_sdk/_generated/__init__.py +10 -0
  6. beakr_sdk/_generated/models.py +4389 -0
  7. beakr_sdk/_generated/operations.py +4764 -0
  8. beakr_sdk/_http_date.py +83 -0
  9. beakr_sdk/_multipart.py +40 -0
  10. beakr_sdk/_pagination.py +41 -0
  11. beakr_sdk/_paths.py +26 -0
  12. beakr_sdk/_redact.py +136 -0
  13. beakr_sdk/_replay.py +89 -0
  14. beakr_sdk/_resource.py +118 -0
  15. beakr_sdk/_sse.py +59 -0
  16. beakr_sdk/_typing.py +115 -0
  17. beakr_sdk/_version.py +3 -0
  18. beakr_sdk/answers.py +224 -0
  19. beakr_sdk/async_client.py +352 -0
  20. beakr_sdk/capabilities.py +169 -0
  21. beakr_sdk/client.py +345 -0
  22. beakr_sdk/credentials.py +1013 -0
  23. beakr_sdk/errors.py +571 -0
  24. beakr_sdk/py.typed +0 -0
  25. beakr_sdk/query_spec.py +182 -0
  26. beakr_sdk/resources/__init__.py +72 -0
  27. beakr_sdk/resources/_base.py +9 -0
  28. beakr_sdk/resources/_destination.py +77 -0
  29. beakr_sdk/resources/_multipart_upload.py +146 -0
  30. beakr_sdk/resources/_upload.py +391 -0
  31. beakr_sdk/resources/agent_review.py +75 -0
  32. beakr_sdk/resources/bulk_uploads.py +56 -0
  33. beakr_sdk/resources/conversations.py +228 -0
  34. beakr_sdk/resources/end_user_tokens.py +75 -0
  35. beakr_sdk/resources/engine.py +74 -0
  36. beakr_sdk/resources/external_users.py +106 -0
  37. beakr_sdk/resources/files.py +537 -0
  38. beakr_sdk/resources/folders.py +71 -0
  39. beakr_sdk/resources/integrations.py +97 -0
  40. beakr_sdk/resources/interviews.py +133 -0
  41. beakr_sdk/resources/jobs.py +138 -0
  42. beakr_sdk/resources/keys.py +74 -0
  43. beakr_sdk/resources/knowledge.py +610 -0
  44. beakr_sdk/resources/knowledge_pages.py +138 -0
  45. beakr_sdk/resources/knowledge_readiness.py +192 -0
  46. beakr_sdk/resources/labels.py +74 -0
  47. beakr_sdk/resources/me.py +43 -0
  48. beakr_sdk/resources/members.py +77 -0
  49. beakr_sdk/resources/model_refusals.py +85 -0
  50. beakr_sdk/resources/org_admin.py +172 -0
  51. beakr_sdk/resources/organizations.py +26 -0
  52. beakr_sdk/resources/projects.py +307 -0
  53. beakr_sdk/resources/runs.py +226 -0
  54. beakr_sdk/resources/skills.py +353 -0
  55. beakr_sdk/resources/uploads.py +82 -0
  56. beakr_sdk/sources.py +229 -0
  57. beakr_sdk-0.1.0.dist-info/METADATA +452 -0
  58. beakr_sdk-0.1.0.dist-info/RECORD +60 -0
  59. beakr_sdk-0.1.0.dist-info/WHEEL +4 -0
  60. beakr_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
beakr_sdk/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """Beakr Python SDK.
2
+
3
+ Sync::
4
+
5
+ from beakr_sdk import Beakr
6
+
7
+ beakr = Beakr() # BEAKR_API_KEY, else the `beakr auth login` credentials
8
+ project = beakr.projects.create("Research")
9
+ beakr.files.upload("paper.pdf", project_id=project["id"])
10
+ print(beakr.knowledge.sections("main result", scope="Research"))
11
+
12
+ Async: ``AsyncBeakr`` takes the same arguments and has the same methods.
13
+ Errors are typed (:mod:`beakr_sdk.errors`).
14
+
15
+ The clients cover the documented public contract: account and keys,
16
+ organization members and service accounts, external users, labels and
17
+ end-user tokens, projects, files, Knowledge, runs, interviews, conversations
18
+ and integrations. ``beakr.api`` has one generated method per documented
19
+ operation. Modules whose names start with an underscore are private.
20
+ """
21
+
22
+ from beakr_sdk._version import __version__
23
+ from beakr_sdk.answers import RunFailedError, RunNeedsInputError
24
+ from beakr_sdk.async_client import AsyncBeakr
25
+ from beakr_sdk.capabilities import Capabilities
26
+ from beakr_sdk.client import Beakr
27
+ from beakr_sdk.errors import (
28
+ APIConnectionError,
29
+ AuthenticationError,
30
+ BeakrError,
31
+ BeakrUploadInterrupted,
32
+ ConflictError,
33
+ KnowledgeCommandError,
34
+ NotFoundError,
35
+ NotProvidedError,
36
+ PermissionDeniedError,
37
+ PersonReviewRequiredError,
38
+ RateLimitError,
39
+ ServerError,
40
+ SessionRequiredError,
41
+ UnexpectedResponseError,
42
+ UnsafePathError,
43
+ ValidationError,
44
+ )
45
+ from beakr_sdk.query_spec import QuerySpec
46
+
47
+ __all__ = [
48
+ "APIConnectionError",
49
+ "AsyncBeakr",
50
+ "AuthenticationError",
51
+ "Beakr",
52
+ "BeakrError",
53
+ "BeakrUploadInterrupted",
54
+ "Capabilities",
55
+ "ConflictError",
56
+ "KnowledgeCommandError",
57
+ "NotFoundError",
58
+ "NotProvidedError",
59
+ "PermissionDeniedError",
60
+ "PersonReviewRequiredError",
61
+ "QuerySpec",
62
+ "RateLimitError",
63
+ "RunFailedError",
64
+ "RunNeedsInputError",
65
+ "ServerError",
66
+ "SessionRequiredError",
67
+ "UnexpectedResponseError",
68
+ "UnsafePathError",
69
+ "ValidationError",
70
+ "__version__",
71
+ ]
@@ -0,0 +1,458 @@
1
+ """Configuration, request building and retry policy shared by both clients.
2
+
3
+ Nothing here performs I/O. :class:`BaseClient` turns a :class:`Call` into an
4
+ ``httpx.Request``, decides whether a failure is retried and for how long to
5
+ wait, decodes a response, and owns the credential (including when it needs a
6
+ refresh). The sync and async clients only add the send/sleep loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import random
13
+ import uuid
14
+ from collections.abc import Callable, Mapping
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import httpx
19
+
20
+ from beakr_sdk._calls import Call, clean_params, oauth_form_call
21
+ from beakr_sdk._redact import mask
22
+ from beakr_sdk._replay import IDEMPOTENCY_RETRY_CODES
23
+ from beakr_sdk._version import __version__
24
+ from beakr_sdk.credentials import (
25
+ Credential,
26
+ StoredTokens,
27
+ TokenStore,
28
+ normalize_origin,
29
+ resolve_base_url,
30
+ resolve_credential,
31
+ resolve_org_id,
32
+ )
33
+ from beakr_sdk.errors import (
34
+ APIConnectionError,
35
+ AuthenticationError,
36
+ BeakrError,
37
+ RateLimitError,
38
+ ServerError,
39
+ parse_retry_after,
40
+ unexpected_response_error,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from beakr_sdk.resources.bulk_uploads import BulkUploads
45
+ from beakr_sdk.resources.conversations import Conversations
46
+ from beakr_sdk.resources.end_user_tokens import EndUserTokens
47
+ from beakr_sdk.resources.engine import Engine
48
+ from beakr_sdk.resources.external_users import ExternalUsers
49
+ from beakr_sdk.resources.files import Files
50
+ from beakr_sdk.resources.folders import Folders
51
+ from beakr_sdk.resources.integrations import Integrations
52
+ from beakr_sdk.resources.interviews import Interviews
53
+ from beakr_sdk.resources.jobs import Jobs
54
+ from beakr_sdk.resources.keys import Keys
55
+ from beakr_sdk.resources.knowledge import Knowledge
56
+ from beakr_sdk.resources.labels import Labels
57
+ from beakr_sdk.resources.me import Me, Meta
58
+ from beakr_sdk.resources.members import Members
59
+ from beakr_sdk.resources.org_admin import OrgAdmin
60
+ from beakr_sdk.resources.organizations import Organizations
61
+ from beakr_sdk.resources.projects import Projects
62
+ from beakr_sdk.resources.runs import Runs, Threads
63
+
64
+ DEFAULT_TIMEOUT = 60.0
65
+ DEFAULT_MAX_RETRIES = 2
66
+
67
+ #: Statuses where the server did not (or may not have) done the work and the
68
+ #: same request can succeed later. Spec W6.1.3: retries on 429 and 5xx. 501
69
+ #: and 505 are left out: they say the server will never do it.
70
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
71
+
72
+ #: 429 refusals that waiting a few seconds cannot fix: the organization is out of
73
+ #: credits, or a usage quota is spent until its window resets. Returned at once.
74
+ NOT_RETRIED_429_CODES = frozenset({"credits_exhausted", "quota_exceeded"})
75
+
76
+ #: Backoff bounds. The first retry waits about half a second, doubling, never
77
+ #: more than the cap -- unless the server asks for longer with Retry-After.
78
+ _BACKOFF_BASE_SECONDS = 0.5
79
+ _BACKOFF_CAP_SECONDS = 8.0
80
+ #: A Retry-After beyond this is not waited out: the caller gets the 429.
81
+ _MAX_RETRY_AFTER_SECONDS = 60.0
82
+
83
+
84
+ def _utc_now() -> datetime:
85
+ return datetime.now(timezone.utc)
86
+
87
+
88
+ class BaseClient:
89
+ """Shared state and policy. Use :class:`beakr_sdk.Beakr` or ``AsyncBeakr``."""
90
+
91
+ # Attached by ``resources.attach_resources``; declared for type checkers.
92
+ keys: Keys
93
+ me: Me
94
+ meta: Meta
95
+ jobs: Jobs
96
+ organizations: Organizations
97
+ members: Members
98
+ org: OrgAdmin
99
+ external_users: ExternalUsers
100
+ labels: Labels
101
+ end_user_tokens: EndUserTokens
102
+ projects: Projects
103
+ files: Files
104
+ bulk_uploads: BulkUploads
105
+ folders: Folders
106
+ knowledge: Knowledge
107
+ runs: Runs
108
+ interviews: Interviews
109
+ threads: Threads
110
+ conversations: Conversations
111
+ engine: Engine
112
+ integrations: Integrations
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ api_key: str | None = None,
118
+ base_url: str | None = None,
119
+ client_name: str | None = None,
120
+ agent: str | None = None,
121
+ run: str | None = None,
122
+ session: str | None = None,
123
+ org_id: str | None = None,
124
+ timeout: float = DEFAULT_TIMEOUT,
125
+ max_retries: int = DEFAULT_MAX_RETRIES,
126
+ default_headers: Mapping[str, str] | None = None,
127
+ token_store: TokenStore | None = None,
128
+ ) -> None:
129
+ self.base_url = resolve_base_url(base_url)
130
+ self.client_name = client_name or f"sdk-python/{__version__}"
131
+ self.agent = agent
132
+ self.run = run
133
+ # Unset means the environment's; "" sends none even when BEAKR_SESSION is set.
134
+ self.session = os.environ.get("BEAKR_SESSION") if session is None else session
135
+ self.org_id = resolve_org_id(org_id)
136
+ self.timeout = timeout
137
+ self.max_retries = max(0, max_retries)
138
+ self._default_headers = dict(default_headers or {})
139
+ self._token_store = token_store or TokenStore()
140
+ self._explicit_api_key = api_key
141
+ self._credential: Credential | None = None
142
+ #: Injectable so tests can pin the clock an HTTP-date ``Retry-After`` is
143
+ #: measured against.
144
+ self._now: Callable[[], datetime] = _utc_now
145
+
146
+ def __repr__(self) -> str:
147
+ # Never resolves the credential (that reads the keychain); shows the one
148
+ # in use, else the explicit key, masked either way.
149
+ credential: object = self._credential
150
+ if credential is None:
151
+ credential = mask(self._explicit_api_key) if self._explicit_api_key else "unresolved"
152
+ return (
153
+ f"{type(self).__name__}(base_url={self.base_url!r}, org_id={self.org_id!r}, "
154
+ f"credential={credential!r})"
155
+ )
156
+
157
+ # -- credentials -------------------------------------------------------
158
+
159
+ @property
160
+ def origin(self) -> str:
161
+ """The normalized API origin this client talks to; stored logins are filed by it."""
162
+ return normalize_origin(self.base_url)
163
+
164
+ @property
165
+ def credential(self) -> Credential:
166
+ """The resolved credential. Resolution is lazy so unauthenticated calls work.
167
+
168
+ A stored login is offered only when it was issued by this client's
169
+ origin (see :mod:`beakr_sdk.credentials`).
170
+ """
171
+ if self._credential is None:
172
+ self._credential = resolve_credential(
173
+ self._explicit_api_key, token_store=self._token_store, base_url=self.base_url
174
+ )
175
+ return self._credential
176
+
177
+ # -- refresh coordination ------------------------------------------------
178
+ #
179
+ # A device-flow refresh rotates the pair, and the engine revokes the whole
180
+ # login when a spent refresh token is presented again. Several processes
181
+ # (the CLI, hooks, an SDK script) share one stored login, so a stored-login
182
+ # refresh runs under the cross-process ``RefreshLock``: reload the store,
183
+ # adopt a pair another process already rotated, and only otherwise spend
184
+ # the refresh token. A refresh whose answer is lost is never retried.
185
+
186
+ def _uses_stored_login(self) -> bool:
187
+ return self.credential.source == "cli_tokens"
188
+
189
+ def _adopt_rotated_pair(self) -> bool:
190
+ """Under the refresh lock: take the stored pair if another process rotated ours."""
191
+ current = self.credential.tokens
192
+ stored = self._token_store.load(self.origin)
193
+ if current is None or stored is None:
194
+ return False
195
+ if (stored.refresh_token, stored.refresh_state) == (
196
+ current.refresh_token,
197
+ current.refresh_state,
198
+ ):
199
+ return False
200
+ self.credential.tokens = stored
201
+ return True
202
+
203
+ def _still_needs_refresh(self, stale_access_token: str | None) -> bool:
204
+ tokens = self.credential.tokens
205
+ if tokens is None:
206
+ return False
207
+ return tokens.access_token == stale_access_token or tokens.access_expired()
208
+
209
+ def _assert_refreshable(self) -> None:
210
+ tokens = self.credential.tokens
211
+ if tokens is not None and tokens.refresh_state == "uncertain":
212
+ raise AuthenticationError(
213
+ "This sign-in cannot be refreshed safely: an earlier refresh may "
214
+ "have succeeded on the server without its answer arriving. Run "
215
+ "`beakr auth login` to sign in again.",
216
+ code="refresh_uncertain",
217
+ )
218
+
219
+ def _settle_failed_refresh(self, exc: BeakrError) -> None:
220
+ """Turn a failed refresh into the right error. Always raises.
221
+
222
+ - Not sent (connection refused, connect timeout) or refused before any
223
+ work (429): the refresh token is untouched; raise the original error.
224
+ - Ambiguous (read timeout, dropped connection, 5xx): the server may have
225
+ rotated the pair. Retrying would present a possibly spent token and
226
+ revoke the login, so mark the pair ``uncertain`` (persisted, so other
227
+ processes stop too) and ask for a new sign-in.
228
+ - Refused (4xx): the login is expired or revoked.
229
+ """
230
+ if isinstance(exc, RateLimitError):
231
+ raise exc
232
+ if isinstance(exc, APIConnectionError) and isinstance(
233
+ exc.__cause__, (httpx.ConnectError, httpx.ConnectTimeout)
234
+ ):
235
+ raise exc
236
+ if isinstance(exc, (APIConnectionError, ServerError)):
237
+ tokens = self.credential.tokens
238
+ if tokens is not None:
239
+ tokens.refresh_state = "uncertain"
240
+ if self._uses_stored_login():
241
+ self._token_store.save(tokens)
242
+ raise AuthenticationError(
243
+ "The sign-in refresh did not complete and its outcome is unknown. "
244
+ "Run `beakr auth login` to sign in again.",
245
+ code="refresh_uncertain",
246
+ ) from exc
247
+ # The issuer states why in RFC 6749's ``error`` (``invalid_grant``).
248
+ oauth_error = exc.body.get("error") if isinstance(exc.body, dict) else None
249
+ raise AuthenticationError(
250
+ "Your sign-in has expired or was revoked. Run `beakr auth login`.",
251
+ code=oauth_error if isinstance(oauth_error, str) else exc.code or "refresh_failed",
252
+ status=exc.status,
253
+ ) from exc
254
+
255
+ def _needs_proactive_refresh(self) -> bool:
256
+ credential = self.credential
257
+ return credential.tokens is not None and credential.tokens.access_expired()
258
+
259
+ def _refresh_call(self) -> Call:
260
+ tokens = self.credential.tokens
261
+ assert tokens is not None, "refresh requested for a credential without tokens"
262
+ if not tokens.token_endpoint:
263
+ # Issued by Beakr's own device flow, which the API no longer serves.
264
+ raise AuthenticationError(
265
+ "This sign-in was made with an older Beakr CLI and cannot be refreshed. "
266
+ "Run `beakr auth login`.",
267
+ code="login_outdated",
268
+ )
269
+ return oauth_form_call(
270
+ tokens.token_endpoint,
271
+ {
272
+ "grant_type": "refresh_token",
273
+ "refresh_token": tokens.refresh_token,
274
+ "client_id": tokens.oauth_client_id or "",
275
+ },
276
+ )
277
+
278
+ def _apply_refreshed_tokens(self, data: dict[str, Any]) -> None:
279
+ """Adopt a rotated pair; persist it when it came from the CLI login.
280
+
281
+ Refresh rotates: the old refresh token is dead the moment the server
282
+ answers, so failing to store the new one would log the user out.
283
+ """
284
+ credential = self.credential
285
+ previous = credential.tokens
286
+ refreshed = StoredTokens.from_token_response(
287
+ data,
288
+ base_url=previous.base_url if previous else self.base_url,
289
+ client_name=previous.client_name if previous else self.client_name,
290
+ )
291
+ if not refreshed.org_id and previous is not None:
292
+ refreshed.org_id = previous.org_id
293
+ if not refreshed.user and previous is not None:
294
+ refreshed.user = previous.user
295
+ if previous is not None:
296
+ refreshed.issuer = previous.issuer
297
+ refreshed.token_endpoint = previous.token_endpoint
298
+ refreshed.oauth_client_id = previous.oauth_client_id
299
+ refreshed.revocation_endpoint = previous.revocation_endpoint
300
+ # An OAuth refresh may not rotate the refresh token.
301
+ if not refreshed.refresh_token:
302
+ refreshed.refresh_token = previous.refresh_token
303
+ credential.tokens = refreshed
304
+ if credential.source == "cli_tokens":
305
+ self._token_store.save(refreshed)
306
+
307
+ # -- request building --------------------------------------------------
308
+
309
+ def _headers_for(self, call: Call, idempotency_key: str | None) -> dict[str, str]:
310
+ if call.external:
311
+ return dict(call.headers)
312
+ headers: dict[str, str] = {
313
+ "User-Agent": f"beakr-sdk-python/{__version__}",
314
+ "X-Beakr-Client": self.client_name,
315
+ "Accept": "application/json",
316
+ **self._default_headers,
317
+ }
318
+ if self.agent:
319
+ headers["X-Beakr-Agent"] = self.agent
320
+ if self.run:
321
+ headers["X-Beakr-Run"] = self.run
322
+ if self.session:
323
+ headers["X-Beakr-Session"] = self.session
324
+ if self.org_id:
325
+ headers["X-Org-Id"] = self.org_id
326
+ if call.authenticated:
327
+ headers.update(self.credential.auth_headers())
328
+ if idempotency_key:
329
+ headers["Idempotency-Key"] = idempotency_key
330
+ headers.update(call.headers)
331
+ return headers
332
+
333
+ def _build_request(self, call: Call, idempotency_key: str | None) -> httpx.Request:
334
+ url = call.path if call.external else f"{self.base_url}{call.path}"
335
+ timeout = max(self.timeout, call.min_timeout or 0.0)
336
+ kwargs: dict[str, Any] = {
337
+ "headers": self._headers_for(call, idempotency_key),
338
+ "extensions": {"timeout": httpx.Timeout(timeout).as_dict()},
339
+ }
340
+ # Only when there are some: httpx replaces a URL's own query string with
341
+ # ``params``, even an empty one, and a presigned S3 URL carries its
342
+ # signature in that query string -- passing ``params={}`` stripped it and
343
+ # every upload PUT was refused by S3.
344
+ params = clean_params(call.params or {})
345
+ if params:
346
+ kwargs["params"] = params
347
+ if call.has_json:
348
+ kwargs["json"] = call.json
349
+ elif call.content is not None:
350
+ kwargs["content"] = call.content
351
+ return httpx.Request(call.method.upper(), url, **kwargs)
352
+
353
+ @staticmethod
354
+ def _new_idempotency_key(call: Call) -> str | None:
355
+ return str(uuid.uuid4()) if call.idempotent else None
356
+
357
+ # -- retry policy ------------------------------------------------------
358
+
359
+ def _retry_delay(
360
+ self,
361
+ call: Call,
362
+ attempt: int,
363
+ response: httpx.Response | None,
364
+ ) -> float | None:
365
+ """Seconds to wait before retrying, or ``None`` to stop.
366
+
367
+ ``response is None`` means the request failed in transport. A 429 is
368
+ retried (the server refused before doing work) unless its code says no
369
+ retry soon can succeed (:data:`NOT_RETRIED_429_CODES`). A 5xx or a
370
+ transport failure is retried only when resending cannot duplicate an
371
+ effect: an idempotent HTTP method, a create the engine replays on its
372
+ ``Idempotency-Key`` (``beakr_sdk._replay``; the same key is resent), or
373
+ a route that deduplicates resends itself. A keyed create's 409
374
+ ``idempotency_in_progress``/``idempotency_superseded`` (an earlier
375
+ attempt still holds the key) is waited out the same way.
376
+ """
377
+ if attempt >= self.max_retries:
378
+ return None
379
+ if response is not None:
380
+ if not self._retryable_response(call, response):
381
+ return None
382
+ hinted = parse_retry_after(response.headers.get("retry-after"), now=self._now())
383
+ if hinted is not None:
384
+ return hinted if hinted <= _MAX_RETRY_AFTER_SECONDS else None
385
+ elif not call.retry_safe:
386
+ return None
387
+ backoff = min(_BACKOFF_CAP_SECONDS, _BACKOFF_BASE_SECONDS * (2**attempt))
388
+ # Full jitter keeps many clients that failed together from retrying together.
389
+ return backoff * (0.5 + random.random() / 2)
390
+
391
+ @staticmethod
392
+ def _retryable_response(call: Call, response: httpx.Response) -> bool:
393
+ status = response.status_code
394
+ if status == 429:
395
+ return _error_code(response) not in NOT_RETRIED_429_CODES
396
+ if status in RETRY_STATUSES:
397
+ return call.retry_safe
398
+ if status == 409 and call.replays_key:
399
+ return _error_code(response) in IDEMPOTENCY_RETRY_CODES
400
+ return False
401
+
402
+ # -- response decoding ---------------------------------------------------
403
+
404
+ @staticmethod
405
+ def _decode(call: Call, response: httpx.Response) -> Any:
406
+ if call.response == "headers":
407
+ return {key.lower(): value for key, value in response.headers.items()}
408
+ if call.response == "none" or response.status_code == 204:
409
+ return None
410
+ if call.response == "bytes":
411
+ return response.content
412
+ if call.response == "text":
413
+ return response.text
414
+ if not response.content:
415
+ return None
416
+ # A JSON-typed call that receives a file must not be decoded as JSON:
417
+ # return the bytes rather than raising or corrupting them.
418
+ content_type = response.headers.get("content-type", "").lower()
419
+ if content_type and not _is_textual(content_type):
420
+ return response.content
421
+ try:
422
+ return response.json()
423
+ except ValueError:
424
+ # An HTML page answering 200 for any path: the base URL is not a Beakr API.
425
+ raise unexpected_response_error(response) from None
426
+
427
+ def _auth_retry_allowed(self, call: Call, response: httpx.Response, refreshed: bool) -> bool:
428
+ """A 401 on an access token that can be refreshed gets one refresh and resend."""
429
+ return (
430
+ response.status_code == 401
431
+ and call.authenticated
432
+ and not call.external
433
+ and not refreshed
434
+ and self.credential.can_refresh
435
+ )
436
+
437
+
438
+ def _error_code(response: httpx.Response) -> str | None:
439
+ """The error code of a refusal: the envelope's, else a structured ``detail``'s."""
440
+ try:
441
+ body = response.json()
442
+ except ValueError:
443
+ return None
444
+ if not isinstance(body, dict):
445
+ return None
446
+ for holder in (body.get("error"), body.get("detail")):
447
+ if isinstance(holder, dict) and isinstance(holder.get("code"), str):
448
+ return holder["code"]
449
+ return None
450
+
451
+
452
+ def _is_textual(content_type: str) -> bool:
453
+ return (
454
+ "json" in content_type
455
+ or content_type.startswith("text/")
456
+ or "xml" in content_type
457
+ or "x-www-form-urlencoded" in content_type
458
+ )