hushscript 1.0.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 (187) hide show
  1. hushscript/__init__.py +97 -0
  2. hushscript/_client.py +687 -0
  3. hushscript/_clock.py +36 -0
  4. hushscript/_config.py +139 -0
  5. hushscript/_errors.py +563 -0
  6. hushscript/_guard.py +61 -0
  7. hushscript/_http.py +624 -0
  8. hushscript/_json.py +55 -0
  9. hushscript/_log.py +155 -0
  10. hushscript/_models.py +144 -0
  11. hushscript/_pacing.py +51 -0
  12. hushscript/_redact.py +140 -0
  13. hushscript/_response.py +67 -0
  14. hushscript/_retry.py +205 -0
  15. hushscript/_shape.py +197 -0
  16. hushscript/_shapes.py +248 -0
  17. hushscript/_version.py +1 -0
  18. hushscript/format.py +224 -0
  19. hushscript/generated/__init__.py +8 -0
  20. hushscript/generated/api/__init__.py +1 -0
  21. hushscript/generated/api/default/__init__.py +1 -0
  22. hushscript/generated/api/default/buy_minutes.py +248 -0
  23. hushscript/generated/api/default/claim_agent_account.py +238 -0
  24. hushscript/generated/api/default/complete_upload.py +238 -0
  25. hushscript/generated/api/default/create_agent_account_signup.py +261 -0
  26. hushscript/generated/api/default/create_setup_session.py +244 -0
  27. hushscript/generated/api/default/create_upload.py +225 -0
  28. hushscript/generated/api/default/get_account.py +169 -0
  29. hushscript/generated/api/default/get_job.py +198 -0
  30. hushscript/generated/api/default/get_org.py +169 -0
  31. hushscript/generated/api/default/get_transcript.py +226 -0
  32. hushscript/generated/api/default/get_transcription_context.py +173 -0
  33. hushscript/generated/api/default/get_upload_part_urls.py +246 -0
  34. hushscript/generated/api/default/heartbeat_upload.py +197 -0
  35. hushscript/generated/api/default/lift_transcript_hold.py +206 -0
  36. hushscript/generated/api/default/list_credit_ledger.py +229 -0
  37. hushscript/generated/api/default/list_deleted_transcripts.py +237 -0
  38. hushscript/generated/api/default/list_holds.py +233 -0
  39. hushscript/generated/api/default/list_jobs.py +249 -0
  40. hushscript/generated/api/default/list_org_audit.py +221 -0
  41. hushscript/generated/api/default/list_org_invoices.py +221 -0
  42. hushscript/generated/api/default/list_org_ledger.py +221 -0
  43. hushscript/generated/api/default/list_org_matters.py +247 -0
  44. hushscript/generated/api/default/list_org_members.py +221 -0
  45. hushscript/generated/api/default/list_org_transcripts.py +233 -0
  46. hushscript/generated/api/default/list_packs.py +169 -0
  47. hushscript/generated/api/default/list_saved_cards.py +169 -0
  48. hushscript/generated/api/default/list_transactions.py +225 -0
  49. hushscript/generated/api/default/list_transcripts.py +225 -0
  50. hushscript/generated/api/default/org_audit_csv.py +163 -0
  51. hushscript/generated/api/default/org_ledger_csv.py +163 -0
  52. hushscript/generated/api/default/org_usage_report.py +247 -0
  53. hushscript/generated/api/default/org_usage_report_csv.py +229 -0
  54. hushscript/generated/api/default/patch_org_member.py +237 -0
  55. hushscript/generated/api/default/place_transcript_hold.py +206 -0
  56. hushscript/generated/api/default/rotate_agent_credentials.py +177 -0
  57. hushscript/generated/api/default/schedule_org_custody.py +215 -0
  58. hushscript/generated/api/default/set_default_card.py +223 -0
  59. hushscript/generated/api/default/upload_part.py +249 -0
  60. hushscript/generated/catalog.py +138 -0
  61. hushscript/generated/client.py +282 -0
  62. hushscript/generated/errors.py +16 -0
  63. hushscript/generated/hints.py +28 -0
  64. hushscript/generated/models/__init__.py +193 -0
  65. hushscript/generated/models/account_response.py +114 -0
  66. hushscript/generated/models/active_hold.py +88 -0
  67. hushscript/generated/models/active_hold_kind.py +18 -0
  68. hushscript/generated/models/agent_claim_request.py +61 -0
  69. hushscript/generated/models/agent_claim_response.py +85 -0
  70. hushscript/generated/models/agent_credentials_rotate_response.py +101 -0
  71. hushscript/generated/models/agent_signup_request.py +118 -0
  72. hushscript/generated/models/agent_signup_request_agent.py +80 -0
  73. hushscript/generated/models/agent_signup_response.py +111 -0
  74. hushscript/generated/models/buy_minutes_body.py +89 -0
  75. hushscript/generated/models/card.py +132 -0
  76. hushscript/generated/models/cards_response.py +90 -0
  77. hushscript/generated/models/complete_upload_body.py +107 -0
  78. hushscript/generated/models/complete_upload_body_parts_type_0_item.py +69 -0
  79. hushscript/generated/models/context_dictionary.py +143 -0
  80. hushscript/generated/models/context_prompt_preset.py +118 -0
  81. hushscript/generated/models/create_setup_session_body.py +62 -0
  82. hushscript/generated/models/create_upload_body.py +254 -0
  83. hushscript/generated/models/create_upload_body_transcription_options_type_0.py +178 -0
  84. hushscript/generated/models/credit_ledger_entry.py +113 -0
  85. hushscript/generated/models/credit_ledger_response.py +90 -0
  86. hushscript/generated/models/deleted_transcript.py +148 -0
  87. hushscript/generated/models/deleted_transcript_cause.py +18 -0
  88. hushscript/generated/models/deleted_transcript_receipt_type_0.py +107 -0
  89. hushscript/generated/models/deleted_transcript_receipt_type_0_items_item.py +69 -0
  90. hushscript/generated/models/deleted_transcripts_response.py +90 -0
  91. hushscript/generated/models/dictionary_entry.py +123 -0
  92. hushscript/generated/models/dictionary_entry_category.py +37 -0
  93. hushscript/generated/models/error.py +155 -0
  94. hushscript/generated/models/error_details.py +94 -0
  95. hushscript/generated/models/get_upload_part_urls_response_200_type_0.py +108 -0
  96. hushscript/generated/models/heartbeat_upload_response_200.py +61 -0
  97. hushscript/generated/models/holds_response.py +90 -0
  98. hushscript/generated/models/job.py +309 -0
  99. hushscript/generated/models/job_failure_kind_type_1.py +16 -0
  100. hushscript/generated/models/job_failure_kind_type_2_type_1.py +16 -0
  101. hushscript/generated/models/job_failure_kind_type_3_type_1.py +16 -0
  102. hushscript/generated/models/job_list.py +90 -0
  103. hushscript/generated/models/lift_transcript_hold_response_200.py +61 -0
  104. hushscript/generated/models/list_jobs_active.py +17 -0
  105. hushscript/generated/models/list_org_matters_status.py +16 -0
  106. hushscript/generated/models/options_summary.py +135 -0
  107. hushscript/generated/models/org_audit_entry.py +105 -0
  108. hushscript/generated/models/org_audit_response.py +90 -0
  109. hushscript/generated/models/org_invoice.py +175 -0
  110. hushscript/generated/models/org_invoices_response.py +90 -0
  111. hushscript/generated/models/org_ledger_entry.py +155 -0
  112. hushscript/generated/models/org_ledger_response.py +90 -0
  113. hushscript/generated/models/org_matter.py +79 -0
  114. hushscript/generated/models/org_matter_status.py +16 -0
  115. hushscript/generated/models/org_matters_response.py +75 -0
  116. hushscript/generated/models/org_member.py +123 -0
  117. hushscript/generated/models/org_members_response.py +90 -0
  118. hushscript/generated/models/org_summary_response.py +482 -0
  119. hushscript/generated/models/org_transcript.py +103 -0
  120. hushscript/generated/models/org_transcripts_response.py +90 -0
  121. hushscript/generated/models/org_usage_report_csv_group_by.py +17 -0
  122. hushscript/generated/models/org_usage_report_group_by.py +17 -0
  123. hushscript/generated/models/org_usage_response.py +112 -0
  124. hushscript/generated/models/org_usage_response_group_by.py +17 -0
  125. hushscript/generated/models/org_usage_row.py +86 -0
  126. hushscript/generated/models/pack.py +107 -0
  127. hushscript/generated/models/packs_response.py +75 -0
  128. hushscript/generated/models/patch_org_member_body.py +102 -0
  129. hushscript/generated/models/patch_org_member_response_200.py +61 -0
  130. hushscript/generated/models/place_transcript_hold_response_200.py +61 -0
  131. hushscript/generated/models/presigned_part_url.py +69 -0
  132. hushscript/generated/models/purchase_response.py +76 -0
  133. hushscript/generated/models/schedule_org_custody_body.py +66 -0
  134. hushscript/generated/models/schedule_org_custody_body_custody.py +17 -0
  135. hushscript/generated/models/schedule_org_custody_response_200.py +69 -0
  136. hushscript/generated/models/set_default_card_body.py +61 -0
  137. hushscript/generated/models/set_default_card_response_200.py +61 -0
  138. hushscript/generated/models/setup_session_response.py +77 -0
  139. hushscript/generated/models/transaction.py +183 -0
  140. hushscript/generated/models/transactions_response.py +98 -0
  141. hushscript/generated/models/transcript_detail.py +194 -0
  142. hushscript/generated/models/transcript_detail_speaker_names.py +47 -0
  143. hushscript/generated/models/transcript_envelope.py +89 -0
  144. hushscript/generated/models/transcript_envelope_translations.py +62 -0
  145. hushscript/generated/models/transcript_language.py +75 -0
  146. hushscript/generated/models/transcript_list.py +90 -0
  147. hushscript/generated/models/transcript_list_row.py +152 -0
  148. hushscript/generated/models/transcript_list_row_insights_state.py +20 -0
  149. hushscript/generated/models/transcription_context_response.py +95 -0
  150. hushscript/generated/models/upload_complete_response.py +100 -0
  151. hushscript/generated/models/upload_create_response.py +148 -0
  152. hushscript/generated/models/upload_create_response_funding.py +16 -0
  153. hushscript/generated/models/upload_create_response_upload_type_0.py +108 -0
  154. hushscript/generated/models/upload_limits.py +86 -0
  155. hushscript/generated/models/upload_part_response_200.py +69 -0
  156. hushscript/generated/models/utterance.py +85 -0
  157. hushscript/generated/scopes.py +42 -0
  158. hushscript/generated/types.py +54 -0
  159. hushscript/money.py +79 -0
  160. hushscript/paginate.py +78 -0
  161. hushscript/py.typed +0 -0
  162. hushscript/resources/__init__.py +1 -0
  163. hushscript/resources/_params.py +33 -0
  164. hushscript/resources/account.py +121 -0
  165. hushscript/resources/agent.py +421 -0
  166. hushscript/resources/billing.py +567 -0
  167. hushscript/resources/jobs.py +105 -0
  168. hushscript/resources/transcripts.py +209 -0
  169. hushscript/transcribe/__init__.py +1 -0
  170. hushscript/transcribe/apipeline.py +234 -0
  171. hushscript/transcribe/aupload.py +197 -0
  172. hushscript/transcribe/heartbeat.py +78 -0
  173. hushscript/transcribe/pipeline.py +312 -0
  174. hushscript/transcribe/preflight.py +272 -0
  175. hushscript/transcribe/probe.py +185 -0
  176. hushscript/transcribe/resume.py +115 -0
  177. hushscript/transcribe/segment.py +23 -0
  178. hushscript/transcribe/session.py +34 -0
  179. hushscript/transcribe/sniff.py +30 -0
  180. hushscript/transcribe/source.py +218 -0
  181. hushscript/transcribe/upload.py +498 -0
  182. hushscript/transcribe/wait.py +124 -0
  183. hushscript/transcript.py +197 -0
  184. hushscript-1.0.0.dist-info/METADATA +502 -0
  185. hushscript-1.0.0.dist-info/RECORD +187 -0
  186. hushscript-1.0.0.dist-info/WHEEL +4 -0
  187. hushscript-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,421 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from dataclasses import dataclass
5
+ from dataclasses import fields as fields_of
6
+ from typing import TYPE_CHECKING, Final, TypeVar
7
+
8
+ from hushscript._config import acceptable_token
9
+ from hushscript._errors import (
10
+ HushscriptError,
11
+ OutcomeUnknownError,
12
+ ValidationError,
13
+ describe_failure,
14
+ forget_body,
15
+ raise_for_response,
16
+ redacted_cause,
17
+ )
18
+ from hushscript._http import UNKNOWN_HINTS
19
+ from hushscript._json import ExtraFields, as_dict, as_list, extra_fields, loads
20
+ from hushscript._redact import MIN_SECRET_TAIL, REDACTED, Secret
21
+ from hushscript._response import bad_response, parsed
22
+ from hushscript._retry import CLAIM, ROTATE
23
+ from hushscript._shape import INT_LIMIT, as_text, as_whole, check_shape, to_datetime
24
+ from hushscript._shapes import AGENT_SIGNUP
25
+ from hushscript.money import Money, to_money
26
+ from hushscript.resources._params import path_id
27
+ from hushscript.resources.account import Balance, minutes_of
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Callable
31
+ from datetime import datetime
32
+
33
+ import httpx
34
+ from _typeshed import DataclassInstance
35
+
36
+ from hushscript._redact import Credentials
37
+ from hushscript.transcribe.session import AsyncSession, SyncSession
38
+
39
+ CLAIM_ROUTE: Final = "POST /v1/agent/accounts/{signup_id}/claim"
40
+ ROTATE_ROUTE: Final = "POST /v1/agent/credentials/rotate"
41
+
42
+ T = TypeVar("T")
43
+
44
+
45
+ def _repr_without(value: DataclassInstance, secret: str) -> str:
46
+ """A dataclass repr that names `secret` and shows the marker in place of its value (R79). The
47
+ field stays a plain readable attribute; `asdict`, `json` and `pickle` carry it."""
48
+ shown = [
49
+ f"{field.name}={REDACTED}"
50
+ if field.name == secret
51
+ else f"{field.name}={getattr(value, field.name)!r}"
52
+ for field in fields_of(value)
53
+ if field.repr
54
+ ]
55
+ return f"{type(value).__name__}({', '.join(shown)})"
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class AgentInfo(ExtraFields):
60
+ """The agent an account is opened for."""
61
+
62
+ name: str
63
+ platform: str
64
+ contact_url: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, repr=False)
68
+ class AgentSignup(ExtraFields):
69
+ """What `agent_signup` returns. `repr` and `str` show `claim_secret` as a marker."""
70
+
71
+ signup_id: str
72
+ """Passed back to `agent_claim` verbatim."""
73
+ claim_secret: str
74
+ """Shown once; `agent_claim` needs it."""
75
+ checkout_url: str
76
+ """The hosted checkout page where the account is paid for; it expires at `expires_at`."""
77
+ price: Money
78
+ expires_at: datetime
79
+
80
+ def __repr__(self) -> str:
81
+ return _repr_without(self, "claim_secret")
82
+
83
+
84
+ @dataclass(frozen=True, slots=True, repr=False)
85
+ class AgentAccount(ExtraFields):
86
+ """What `agent_claim` returns. `repr` and `str` show `token` as a marker."""
87
+
88
+ token: str
89
+ """The account's token, shown once: write it down before anything else can fail."""
90
+ user_id: str
91
+ scopes: list[str]
92
+ balance: Balance
93
+ """The pack just granted. A new account holds nothing and has no expiry in the answer."""
94
+
95
+ __hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
96
+
97
+ def __repr__(self) -> str:
98
+ return _repr_without(self, "token")
99
+
100
+
101
+ @dataclass(frozen=True, slots=True, repr=False)
102
+ class RotatedToken(ExtraFields):
103
+ """What `rotate_token` returns. `repr` and `str` show `token` as a marker."""
104
+
105
+ token: str
106
+ """The successor token, shown once; the client already sends it."""
107
+ token_id: str
108
+ scopes: list[str]
109
+ expires_at: datetime | None
110
+ """None when the token never expires."""
111
+
112
+ __hash__ = None # type: ignore[assignment] # holds a list, so it is unhashable by design
113
+
114
+ def __repr__(self) -> str:
115
+ return _repr_without(self, "token")
116
+
117
+
118
+ def accept_terms_required() -> ValidationError:
119
+ return ValidationError(
120
+ "accept_terms is required: pass the policy version you accept. "
121
+ "It is never filled in for you.",
122
+ code="accept_terms_required",
123
+ )
124
+
125
+
126
+ def check_accept_terms(accept_terms: object) -> str:
127
+ if not isinstance(accept_terms, str) or not accept_terms.strip():
128
+ raise accept_terms_required()
129
+ return accept_terms
130
+
131
+
132
+ def signup_body( # mirrors the keyword surface of `agent_signup`
133
+ *,
134
+ email: str,
135
+ pack: str,
136
+ accept_terms: str,
137
+ agent: AgentInfo,
138
+ data_region: str | None,
139
+ dry_run: bool,
140
+ ) -> dict[str, object]:
141
+ who: dict[str, object] = {"name": agent.name, "platform": agent.platform}
142
+ if agent.contact_url is not None:
143
+ who["contact_url"] = agent.contact_url
144
+ body: dict[str, object] = {
145
+ "contact_email": email,
146
+ "pack_id": pack,
147
+ "accept_terms_version": accept_terms,
148
+ "agent": who,
149
+ }
150
+ if data_region is not None:
151
+ body["data_region"] = data_region
152
+ if dry_run:
153
+ body["dry_run"] = True
154
+ return body
155
+
156
+
157
+ def signup_of(body: object) -> AgentSignup:
158
+ fields = as_dict(body) or {}
159
+ # R81: the secret is held in a Secret until the result is built, and the body other frames
160
+ # share carries the marker, which is still a string to the shape check
161
+ text = fields.get("claim_secret")
162
+ claim_secret = Secret(text if isinstance(text, str) else "")
163
+ if isinstance(text, str):
164
+ fields["claim_secret"] = REDACTED
165
+ del text
166
+ check_shape(body, AGENT_SIGNUP, "signup response")
167
+ return AgentSignup(
168
+ _extra=extra_fields(
169
+ fields, "signup_id", "claim_secret", "checkout_url", "amount", "currency", "expires_at"
170
+ ),
171
+ signup_id=as_text(fields["signup_id"]),
172
+ claim_secret=claim_secret.reveal(),
173
+ checkout_url=as_text(fields["checkout_url"]),
174
+ price=to_money(as_whole(fields["amount"]), as_text(fields["currency"])),
175
+ expires_at=to_datetime(fields["expires_at"]),
176
+ )
177
+
178
+
179
+ def _strings(value: object) -> list[str] | None:
180
+ items = as_list(value)
181
+ if items is None or not all(isinstance(item, str) for item in items):
182
+ return None
183
+ return [as_text(item) for item in items]
184
+
185
+
186
+ def _whole(value: object) -> bool:
187
+ return isinstance(value, int) and not isinstance(value, bool)
188
+
189
+
190
+ def _count(value: object) -> bool:
191
+ return _whole(value) and isinstance(value, int) and 0 <= value < INT_LIMIT
192
+
193
+
194
+ def _claim_usable(fields: dict[str, object]) -> bool:
195
+ return (
196
+ isinstance(fields.get("user_id"), str)
197
+ and _strings(fields.get("scopes")) is not None
198
+ and _count(fields.get("balance_seconds"))
199
+ )
200
+
201
+
202
+ def _expiry_usable(fields: dict[str, object]) -> bool:
203
+ expires = fields.get("expires_at")
204
+ return expires is None or (
205
+ _whole(expires) and isinstance(expires, int) and 0 <= expires <= _MAX_SECONDS
206
+ )
207
+
208
+
209
+ _MAX_SECONDS: Final = 253_402_300_799
210
+
211
+
212
+ def _rotate_usable(fields: dict[str, object]) -> bool:
213
+ return (
214
+ isinstance(fields.get("pat_id"), str)
215
+ and _strings(fields.get("scopes")) is not None
216
+ and _expiry_usable(fields)
217
+ )
218
+
219
+
220
+ def _decode(response: httpx.Response) -> dict[str, object]:
221
+ try:
222
+ return as_dict(loads(response.content)) or {}
223
+ except (ValueError, RecursionError):
224
+ return {}
225
+
226
+
227
+ def _unknown(
228
+ route: str,
229
+ hint_key: str,
230
+ seen: Secret | None,
231
+ credentials: Credentials,
232
+ reason: str | None = None,
233
+ ) -> OutcomeUnknownError:
234
+ because = "" if reason is None else f" ({reason})"
235
+ return OutcomeUnknownError(
236
+ credentials.scrub(
237
+ f"The answer to {route} could not be used{because}, so the token may be lost."
238
+ ),
239
+ hint=UNKNOWN_HINTS[hint_key],
240
+ received_token=seen,
241
+ )
242
+
243
+
244
+ def lost_credential( # one place that builds the spec 7.4 error
245
+ response: httpx.Response,
246
+ route: str,
247
+ hint_key: str,
248
+ seen: Secret | None,
249
+ credentials: Credentials,
250
+ ) -> OutcomeUnknownError:
251
+ """A claim or rotation answered 2xx without a usable credential: it was minted and may be
252
+ lost, exactly as if the answer never arrived (spec 7.4). `seen` is whatever the answer carried
253
+ in its `pat` field, kept on the error so it is not lost."""
254
+ failure = _unknown(route, hint_key, seen, credentials)
255
+ failure.__cause__ = bad_response(response, "credential response")
256
+ return failure
257
+
258
+
259
+ def _stray_credential(
260
+ failure: Exception, route: str, hint_key: str, seen: Secret | None, credentials: Credentials
261
+ ) -> OutcomeUnknownError:
262
+ """The same loss when the SDK itself failed on the answer (R61): the original rides along as
263
+ a redacted cause, and `seen` is kept on the error."""
264
+ lost = _unknown(route, hint_key, seen, credentials, describe_failure(failure, credentials))
265
+ lost.__cause__ = redacted_cause(failure, credentials)
266
+ return lost
267
+
268
+
269
+ def _take_pat(fields: dict[str, object]) -> Secret | None:
270
+ """The `pat` the answer carried, moved out of the decoded body into a Secret so no frame that
271
+ shares the body holds it as text (R81)."""
272
+ pat = fields.pop("pat", None)
273
+ return Secret(pat) if isinstance(pat, str) else None
274
+
275
+
276
+ def _register(seen: Secret | None, credentials: Credentials) -> None:
277
+ """Scrubs a token the answer carried from every text from now on, trimmed, once it passes the
278
+ token check; anything else the server put in `pat` is never registered."""
279
+ if seen is not None and acceptable_token(seen.reveal().strip()):
280
+ credentials.remember(seen.reveal().strip())
281
+
282
+
283
+ def settle(
284
+ response: httpx.Response,
285
+ credentials: Credentials,
286
+ route: str,
287
+ hint_key: str,
288
+ use: Callable[[dict[str, object], Secret | None], T],
289
+ ) -> T:
290
+ """`use` of a claim or rotation answer. A failure of the SDK's own while reading it, after the
291
+ answer came, is as lost as an answer that never arrived (spec 7.4 and R61): an
292
+ `OutcomeUnknownError` that keeps the `pat` the answer carried. It is raised once the handler
293
+ has ended, so the unredacted original is not left on its `__context__`."""
294
+ raise_for_response(response, credentials=credentials)
295
+ seen: Secret | None = None
296
+ lost: HushscriptError
297
+ fields: dict[str, object] = {}
298
+ try:
299
+ fields = _decode(response)
300
+ seen = _take_pat(fields)
301
+ _register(seen, credentials)
302
+ return use(fields, seen)
303
+ except HushscriptError as failure:
304
+ traceback.clear_frames(failure.__traceback__)
305
+ lost = failure
306
+ except Exception as failure: # noqa: BLE001 # R61: nothing but an SDK error leaves
307
+ lost = _stray_credential(failure, route, hint_key, seen, credentials)
308
+ finally:
309
+ del fields
310
+ forget_body(response) # the body held the token as text
311
+ raise lost
312
+
313
+
314
+ def account_of(token: Secret, fields: dict[str, object]) -> AgentAccount:
315
+ seconds = as_whole(fields["balance_seconds"])
316
+ return AgentAccount(
317
+ _extra=extra_fields(fields, "pat", "user_id", "scopes", "balance_seconds"),
318
+ token=token.reveal(),
319
+ user_id=as_text(fields["user_id"]),
320
+ scopes=_strings(fields["scopes"]) or [],
321
+ balance=Balance(
322
+ seconds=seconds,
323
+ minutes=minutes_of(seconds),
324
+ held_seconds=0,
325
+ expires_at=None,
326
+ purchases_remaining=None,
327
+ ),
328
+ )
329
+
330
+
331
+ def rotated_of(token: Secret, fields: dict[str, object]) -> RotatedToken:
332
+ expires = fields.get("expires_at")
333
+ return RotatedToken(
334
+ _extra=extra_fields(fields, "pat", "pat_id", "scopes", "expires_at"),
335
+ token=token.reveal(),
336
+ token_id=as_text(fields["pat_id"]),
337
+ scopes=_strings(fields["scopes"]) or [],
338
+ expires_at=None if expires is None else to_datetime(expires),
339
+ )
340
+
341
+
342
+ def claim_path(signup_id: object) -> str:
343
+ return f"/v1/agent/accounts/{path_id(signup_id)}/claim"
344
+
345
+
346
+ def remember_claim_secret(credentials: Credentials, claim_secret: Secret) -> None:
347
+ """R96: the claim secret is scrubbed from every error text while the claim runs, like the
348
+ token. A text shorter than a token's secret part is not one the server issues, and scrubbing
349
+ it would cut every word that contains it, so it is left out."""
350
+ if len(claim_secret.reveal()) >= MIN_SECRET_TAIL:
351
+ credentials.remember(claim_secret.reveal())
352
+
353
+
354
+ def claim_body(claim_secret: Secret) -> dict[str, object]:
355
+ return {"claim_secret": claim_secret.reveal()}
356
+
357
+
358
+ def signed_up(response: httpx.Response, credentials: Credentials) -> AgentSignup:
359
+ try:
360
+ return parsed(response, credentials, signup_of)
361
+ finally:
362
+ forget_body(response) # the body held the claim secret as text
363
+
364
+
365
+ def claimed(response: httpx.Response, credentials: Credentials) -> AgentAccount:
366
+ def use(fields: dict[str, object], seen: Secret | None) -> AgentAccount:
367
+ if seen is None or not acceptable_token(seen.reveal()) or not _claim_usable(fields):
368
+ raise lost_credential(response, CLAIM_ROUTE, CLAIM, seen, credentials)
369
+ return account_of(seen, fields)
370
+
371
+ return settle(response, credentials, CLAIM_ROUTE, CLAIM, use)
372
+
373
+
374
+ def rotated(response: httpx.Response, credentials: Credentials) -> RotatedToken:
375
+ """The rotation answer. The server revoked the old token when it answered 2xx, so a token that
376
+ passes the token check is the one the client sends from then on (spec 7.4), whatever else is
377
+ wrong with the answer, and whatever the SDK then fails on: the error carries it as
378
+ `received_token`, so the caller always holds the token the client sends. The one exception is
379
+ an expiry that cannot be read, as in the TypeScript SDK: the answer is then not trusted at all,
380
+ the old token stays in use and the new one is only kept on the error."""
381
+
382
+ def use(fields: dict[str, object], seen: Secret | None) -> RotatedToken:
383
+ token_usable = seen is not None and acceptable_token(seen.reveal())
384
+ if seen is not None and token_usable and _expiry_usable(fields):
385
+ credentials.replace(seen.reveal())
386
+ if seen is None or not token_usable or not _rotate_usable(fields):
387
+ raise lost_credential(response, ROTATE_ROUTE, ROTATE, seen, credentials)
388
+ return rotated_of(seen, fields)
389
+
390
+ return settle(response, credentials, ROTATE_ROUTE, ROTATE, use)
391
+
392
+
393
+ class Agent:
394
+ """`hs.agent`: the calls an agent account makes with its own token."""
395
+
396
+ def __init__(self, session: SyncSession) -> None:
397
+ self._session = session
398
+
399
+ def rotate_token(self) -> RotatedToken:
400
+ """Mints a successor token and revokes the one in use. The client sends the new token from
401
+ then on, to retries already queued as well; store the returned one right away. It is never
402
+ retried. Apart from an API error answer, a failure after the request was sent is an
403
+ `OutcomeUnknownError` whose hint says what to check: a lost answer, an answer without a
404
+ usable token, or a failure of the SDK's own while reading it. The error's `received_token`
405
+ holds the token the answer carried; the client sends it from then on when it passes the
406
+ token check and the expiry can be read, and keeps its old token otherwise."""
407
+ response = self._session.http.post("/v1/agent/credentials/rotate")
408
+ return rotated(response, self._session.credentials)
409
+
410
+
411
+ class AsyncAgent:
412
+ """`hs.agent` on the asyncio client."""
413
+
414
+ def __init__(self, session: AsyncSession) -> None:
415
+ self._session = session
416
+
417
+ async def rotate_token(self) -> RotatedToken:
418
+ """`hs.agent.rotate_token()` on the asyncio client; a failure after the request was sent, an
419
+ API error answer apart, is the same `OutcomeUnknownError` with `received_token`."""
420
+ response = await self._session.http.post("/v1/agent/credentials/rotate")
421
+ return rotated(response, self._session.credentials)