cloudraker-milliseconds 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ .venv
4
+ __pycache__
5
+ .pytest_cache
6
+ .ruff_cache
7
+ *.egg-info
8
+ python/dist
9
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 milliseconds.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,328 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudraker-milliseconds
3
+ Version: 0.1.1
4
+ Summary: Typed decisions over text. The SDK for decision-machine-1 at milliseconds.ai.
5
+ Project-URL: Homepage, https://milliseconds.ai
6
+ Project-URL: Documentation, https://docs.milliseconds.ai
7
+ Project-URL: Source, https://github.com/CloudRaker/milliseconds-sdk
8
+ Author: CloudRaker
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: classification,decision-machine-1,extraction,milliseconds,nlp
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.28
19
+ Requires-Dist: typing-extensions>=4.12; python_version < '3.11'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # cloudraker-milliseconds
23
+
24
+ `decision-machine-1` decides about text. It answers a statement yes or no, picks a label,
25
+ walks a label tree, rates the text on a scale, quotes an answer with its offsets, fills a
26
+ JSON Schema, finds entities, and checks a value. It never generates prose. Each call takes
27
+ milliseconds.
28
+
29
+ This SDK adds the typed vocabulary. It keeps every wire name, unwraps three envelopes,
30
+ validates your arguments before it spends a token, retries the right failures, and raises
31
+ one exception tree you can catch by class.
32
+
33
+ ```bash
34
+ pip install cloudraker-milliseconds
35
+ export MS_API_KEY=sk-ms-... # get a key at https://console.milliseconds.ai
36
+ ```
37
+
38
+ ## What Python can and cannot infer
39
+
40
+ Python has no mapped types and no conditional return types. It cannot read your label names
41
+ out of a dict display. It **can** solve a `TypeVar` from an annotated constant. One
42
+ annotation buys the whole chain.
43
+
44
+ ```python
45
+ from typing import Final, Literal, Mapping
46
+
47
+ from milliseconds import DecisionMachine
48
+
49
+ Intent = Literal["billing", "shipping", "account"]
50
+
51
+ LABELS: Final[Mapping[Intent, str]] = {
52
+ "billing": "payments, invoices, charges and refunds",
53
+ "shipping": "delivery, tracking and packages",
54
+ "account": "login, passwords and profile settings",
55
+ }
56
+
57
+ dm = DecisionMachine() # reads MS_API_KEY
58
+
59
+ r = dm.classify("I was charged twice.", LABELS)
60
+ r.label # Intent. A match statement over it is exhaustive.
61
+ r.scores["billing"] # ok. r.scores["refunds"] is a type error.
62
+ r.probability # float
63
+ r.confidence # float. 1 = one clear winner, 0 = flat.
64
+ ```
65
+
66
+ Without the annotation you get `ClassifyResult[str]`. Nothing breaks. You lose only the
67
+ names. Keep every label set in one constants file and the annotation lands where the
68
+ constants already live.
69
+
70
+ `classify_tree` returns `label: str`. Python has no expression that reads literals out of a
71
+ nested dict.
72
+
73
+ ## The eight capabilities
74
+
75
+ ```python
76
+ statement = dm.yes_no(
77
+ "Fix this today.",
78
+ "The customer expresses urgency.",
79
+ when_true="Time pressure, ASAP, losing money",
80
+ )
81
+ statement.answer # bool
82
+ statement.probability # float
83
+
84
+ label = dm.classify("I was charged twice.", LABELS)
85
+
86
+ tree = dm.classify_tree(
87
+ "I want my money back.",
88
+ {
89
+ "billing": {
90
+ "description": "payments, invoices, charges, refunds and subscriptions",
91
+ "labels": {
92
+ "refund_request": "the customer asks for money back",
93
+ "subscription_change": "the customer wants to upgrade or cancel a plan",
94
+ },
95
+ },
96
+ "shipping": "delivery, tracking, lost or damaged parcels",
97
+ },
98
+ )
99
+ tree.path # winning label per level, top to bottom
100
+ tree.label # the deepest label
101
+
102
+ mood = dm.rate(
103
+ "I am done with this company.",
104
+ ["Calm", "Annoyed", "Angry", "Threatening to leave"],
105
+ )
106
+ mood.score # 0 to len(scale) - 1. Route on this.
107
+ mood.level # the most likely index. It flips on 0.001.
108
+
109
+ who = dm.answer("Apple announced the M5 today.", "Who announced the product?")
110
+ if who.span is not None:
111
+ start, end = who.span
112
+
113
+ people = dm.entities("Ada met Grace in Paris.", {"person": "a human name", "place": "a city"})
114
+ [e.text for e in people]
115
+
116
+ check = dm.verify("Invoice 4471, total 120.00 EUR.", "invoice_number", 4471)
117
+ check.matches # bool
118
+ check.found # what the text actually says
119
+ ```
120
+
121
+ Describe every label. The label text is the instruction, and the model reads it literally.
122
+ Described labels score measurably better than bare names.
123
+
124
+ Annotate a tree you keep in a constant, as you annotate a label set. A bare `TAXONOMY = {...}`
125
+ infers a wider type, and `classify_tree` then refuses it.
126
+
127
+ ```python
128
+ from typing import Final
129
+
130
+ from milliseconds import Tree
131
+
132
+ TAXONOMY: Final[Tree] = {
133
+ "billing": {
134
+ "description": "payments, invoices, charges, refunds and subscriptions",
135
+ "labels": {
136
+ "refund_request": "the customer asks for money back",
137
+ "subscription_change": "the customer wants to upgrade or cancel a plan",
138
+ },
139
+ },
140
+ "shipping": "delivery, tracking, lost or damaged parcels",
141
+ }
142
+
143
+ walked = dm.classify_tree("I want my money back.", TAXONOMY)
144
+ walked.label
145
+ ```
146
+
147
+ ## Batching
148
+
149
+ Pass a list of texts for a batch. The reply follows your request, never the other way.
150
+
151
+ ```python
152
+ tickets = ["I was charged twice.", "Where is my parcel?"]
153
+
154
+ many = dm.classify(tickets, LABELS) # Results[ClassifyResult[Intent]]
155
+ many[0].label
156
+ many.usage.input_tokens # the usage of the one call
157
+
158
+ grid = dm.yes_no(tickets, ["The text mentions a price.", "The customer is angry."])
159
+ grid[0][1].answer # text 0, statement 1
160
+ ```
161
+
162
+ The limits are 32 texts per call, and 20,000 characters per text. The SDK never splits a
163
+ batch for you. Splitting costs money and changes failure modes, so you decide.
164
+
165
+ ## Extraction
166
+
167
+ Four schema shapes work: a plain `dict` JSON Schema, a `TypedDict`, a dataclass, and a
168
+ pydantic v2 model. The SDK never imports pydantic. It calls `model_json_schema()` by duck
169
+ typing.
170
+
171
+ **Declare every field `| None`.** A missing value comes back as `None`.
172
+
173
+ ```python
174
+ from typing import Literal, TypedDict
175
+
176
+
177
+ class Invoice(TypedDict):
178
+ invoice_number: str | None
179
+ total: float | None
180
+ currency: Literal["USD", "EUR"] | None
181
+
182
+
183
+ data = dm.extract("Invoice 4471, total 120.00 EUR.", Invoice)
184
+ data["total"] # float | None
185
+ ```
186
+
187
+ A plain dict carries descriptions, which raise accuracy:
188
+
189
+ ```python
190
+ schema = {
191
+ "type": "object",
192
+ "properties": {
193
+ "invoice_number": {"description": "the identifier printed on the invoice"},
194
+ "total": {"type": "number", "description": "the amount due including tax"},
195
+ "tags": {"type": "array", "items": {"type": "string"}},
196
+ },
197
+ }
198
+ invoice = dm.extract("Invoice 4471, total 120.00 EUR.", schema)
199
+ ```
200
+
201
+ Four degradations are real, and no Python annotation can hide them:
202
+
203
+ - a missing value is `None`;
204
+ - an array of objects always comes back `[]`;
205
+ - an array of scalars comes back as a list of strings;
206
+ - an enum is not checked on the server, so a value outside your `Literal` can arrive.
207
+
208
+ ## Usage and rate limits
209
+
210
+ Every result carries the usage of the call that produced it. A single-text `extract` and
211
+ `post()` are the two exceptions. Both return your own object, which has no place for the
212
+ usage. Send a one-text batch to reach it: `dm.extract([text], Invoice).usage`.
213
+
214
+ ```python
215
+ r = dm.classify("I was charged twice.", LABELS)
216
+ r.usage.input_chars
217
+ r.usage.input_tokens # what this call bills
218
+ r.usage.inference_ms # model time, not wall clock
219
+ r.usage.headers["x-input-tokens"] # every response header stays reachable
220
+
221
+ limits = r.usage.rate_limit # RateLimit | None
222
+ if limits is not None:
223
+ limits.remaining_requests
224
+ limits.reset_requests # '5m0s'
225
+ ```
226
+
227
+ The rate-limit numbers come from the previous request at that Cloudflare colo. The server
228
+ accounts after the response. Read them as a trailing gauge. Never build admission control
229
+ on them.
230
+
231
+ ## Errors and retries
232
+
233
+ ```python
234
+ from milliseconds import (
235
+ AuthenticationError,
236
+ InvalidRequestError,
237
+ MillisecondsError,
238
+ OverloadedError,
239
+ QuotaExceededError,
240
+ RateLimitError,
241
+ )
242
+
243
+ try:
244
+ r = dm.classify("I was charged twice.", LABELS)
245
+ except RateLimitError as e:
246
+ print(e.retry_after, e.attempts)
247
+ except QuotaExceededError:
248
+ print("add credits at https://console.milliseconds.ai")
249
+ except MillisecondsError as e:
250
+ print(e.code, e.status, e.api_message)
251
+ ```
252
+
253
+ The SDK retries `429 rate_limit_exceeded`, `502 runner_error`, `529 overloaded`, and
254
+ transport failures. Every capability is a pure function, so a retry is always safe. It never
255
+ retries `400`, `401` or `429 insufficient_quota`. A timer retry cannot fix a spent quota.
256
+
257
+ Pass `max_retries=0` to turn retries off. `max_retries` and `timeout` also work per call:
258
+ `dm.classify(text, LABELS, max_retries=5, timeout=10.0)`.
259
+
260
+ Some checks run before any HTTP call. They raise `InvalidRequestError` with code
261
+ `client_error` and status `0`. Nothing was sent, so no token was billed.
262
+
263
+ ```python
264
+ try:
265
+ dm.classify("I was charged twice.", ["billing"])
266
+ except InvalidRequestError as e:
267
+ print(e.code) # client_error
268
+ print(e.api_message) # labels has 1 entry. classify needs 2 to 64.
269
+ ```
270
+
271
+ ## What the SDK changes, and nothing else
272
+
273
+ | Wire | SDK | Why |
274
+ | --- | --- | --- |
275
+ | `{ "results": [...] }` | a plain list | one envelope less. The order is already guaranteed. |
276
+ | `{ "entities": [...] }` | a plain list | the same |
277
+ | `{ "data": {...} }` | the object itself | the same |
278
+ | `text` / `texts` | one positional argument | the mutual exclusion becomes impossible |
279
+ | `statement` / `statements` | one positional argument | the same |
280
+ | `question` / `questions` | one positional argument | the same |
281
+ | `x-*` headers | `.usage` | the headers stay reachable, the results stay clean |
282
+
283
+ Every other field keeps its exact wire name, `snake_case` included: `when_true`,
284
+ `input_chars`, `inference_ms`, `probability`, `scores`, `start`, `end`.
285
+
286
+ `dm.post()` reaches the untouched body:
287
+
288
+ ```python
289
+ raw = dm.post("/v1/decision-machine-1/classify", {"text": "hi", "labels": ["a", "b"]})
290
+ raw["label"]
291
+ ```
292
+
293
+ ## Async, and your own pool
294
+
295
+ ```python
296
+ import asyncio
297
+
298
+ from milliseconds import AsyncDecisionMachine
299
+
300
+
301
+ async def main() -> None:
302
+ async with AsyncDecisionMachine() as adm:
303
+ r = await adm.classify("I was charged twice.", LABELS)
304
+ print(r.label)
305
+
306
+
307
+ asyncio.run(main())
308
+ ```
309
+
310
+ Both clients share one transport module, so the retries, the error parsing and the header
311
+ parsing cannot drift. Pass `http_client=` to bring your own `httpx.Client` or
312
+ `httpx.AsyncClient`. The SDK closes only a pool it opened itself.
313
+
314
+ ## Gotchas
315
+
316
+ - `yes-no` and `answer` are the two endpoints with no `text` refinement on the server. A body
317
+ with no text returns `200` and `{"results": []}`. The SDK rejects that body instead.
318
+ - A `labels` or `types` dict has no size limit on the server. The 2-to-64 rule binds the list
319
+ form only, and the SDK checks the same way.
320
+ - `classify_tree` re-sends the text at every level. The per-level `input_chars` therefore do
321
+ not sum to `usage.input_chars`, which counts one pass over the body.
322
+ - The SDK reports `x-input-tokens`. It never estimates a cost.
323
+
324
+ ## Links
325
+
326
+ - Docs: https://docs.milliseconds.ai
327
+ - Console and keys: https://console.milliseconds.ai
328
+ - The TypeScript SDK and the `dm1` CLI: `@cloudraker/milliseconds`
@@ -0,0 +1,307 @@
1
+ # cloudraker-milliseconds
2
+
3
+ `decision-machine-1` decides about text. It answers a statement yes or no, picks a label,
4
+ walks a label tree, rates the text on a scale, quotes an answer with its offsets, fills a
5
+ JSON Schema, finds entities, and checks a value. It never generates prose. Each call takes
6
+ milliseconds.
7
+
8
+ This SDK adds the typed vocabulary. It keeps every wire name, unwraps three envelopes,
9
+ validates your arguments before it spends a token, retries the right failures, and raises
10
+ one exception tree you can catch by class.
11
+
12
+ ```bash
13
+ pip install cloudraker-milliseconds
14
+ export MS_API_KEY=sk-ms-... # get a key at https://console.milliseconds.ai
15
+ ```
16
+
17
+ ## What Python can and cannot infer
18
+
19
+ Python has no mapped types and no conditional return types. It cannot read your label names
20
+ out of a dict display. It **can** solve a `TypeVar` from an annotated constant. One
21
+ annotation buys the whole chain.
22
+
23
+ ```python
24
+ from typing import Final, Literal, Mapping
25
+
26
+ from milliseconds import DecisionMachine
27
+
28
+ Intent = Literal["billing", "shipping", "account"]
29
+
30
+ LABELS: Final[Mapping[Intent, str]] = {
31
+ "billing": "payments, invoices, charges and refunds",
32
+ "shipping": "delivery, tracking and packages",
33
+ "account": "login, passwords and profile settings",
34
+ }
35
+
36
+ dm = DecisionMachine() # reads MS_API_KEY
37
+
38
+ r = dm.classify("I was charged twice.", LABELS)
39
+ r.label # Intent. A match statement over it is exhaustive.
40
+ r.scores["billing"] # ok. r.scores["refunds"] is a type error.
41
+ r.probability # float
42
+ r.confidence # float. 1 = one clear winner, 0 = flat.
43
+ ```
44
+
45
+ Without the annotation you get `ClassifyResult[str]`. Nothing breaks. You lose only the
46
+ names. Keep every label set in one constants file and the annotation lands where the
47
+ constants already live.
48
+
49
+ `classify_tree` returns `label: str`. Python has no expression that reads literals out of a
50
+ nested dict.
51
+
52
+ ## The eight capabilities
53
+
54
+ ```python
55
+ statement = dm.yes_no(
56
+ "Fix this today.",
57
+ "The customer expresses urgency.",
58
+ when_true="Time pressure, ASAP, losing money",
59
+ )
60
+ statement.answer # bool
61
+ statement.probability # float
62
+
63
+ label = dm.classify("I was charged twice.", LABELS)
64
+
65
+ tree = dm.classify_tree(
66
+ "I want my money back.",
67
+ {
68
+ "billing": {
69
+ "description": "payments, invoices, charges, refunds and subscriptions",
70
+ "labels": {
71
+ "refund_request": "the customer asks for money back",
72
+ "subscription_change": "the customer wants to upgrade or cancel a plan",
73
+ },
74
+ },
75
+ "shipping": "delivery, tracking, lost or damaged parcels",
76
+ },
77
+ )
78
+ tree.path # winning label per level, top to bottom
79
+ tree.label # the deepest label
80
+
81
+ mood = dm.rate(
82
+ "I am done with this company.",
83
+ ["Calm", "Annoyed", "Angry", "Threatening to leave"],
84
+ )
85
+ mood.score # 0 to len(scale) - 1. Route on this.
86
+ mood.level # the most likely index. It flips on 0.001.
87
+
88
+ who = dm.answer("Apple announced the M5 today.", "Who announced the product?")
89
+ if who.span is not None:
90
+ start, end = who.span
91
+
92
+ people = dm.entities("Ada met Grace in Paris.", {"person": "a human name", "place": "a city"})
93
+ [e.text for e in people]
94
+
95
+ check = dm.verify("Invoice 4471, total 120.00 EUR.", "invoice_number", 4471)
96
+ check.matches # bool
97
+ check.found # what the text actually says
98
+ ```
99
+
100
+ Describe every label. The label text is the instruction, and the model reads it literally.
101
+ Described labels score measurably better than bare names.
102
+
103
+ Annotate a tree you keep in a constant, as you annotate a label set. A bare `TAXONOMY = {...}`
104
+ infers a wider type, and `classify_tree` then refuses it.
105
+
106
+ ```python
107
+ from typing import Final
108
+
109
+ from milliseconds import Tree
110
+
111
+ TAXONOMY: Final[Tree] = {
112
+ "billing": {
113
+ "description": "payments, invoices, charges, refunds and subscriptions",
114
+ "labels": {
115
+ "refund_request": "the customer asks for money back",
116
+ "subscription_change": "the customer wants to upgrade or cancel a plan",
117
+ },
118
+ },
119
+ "shipping": "delivery, tracking, lost or damaged parcels",
120
+ }
121
+
122
+ walked = dm.classify_tree("I want my money back.", TAXONOMY)
123
+ walked.label
124
+ ```
125
+
126
+ ## Batching
127
+
128
+ Pass a list of texts for a batch. The reply follows your request, never the other way.
129
+
130
+ ```python
131
+ tickets = ["I was charged twice.", "Where is my parcel?"]
132
+
133
+ many = dm.classify(tickets, LABELS) # Results[ClassifyResult[Intent]]
134
+ many[0].label
135
+ many.usage.input_tokens # the usage of the one call
136
+
137
+ grid = dm.yes_no(tickets, ["The text mentions a price.", "The customer is angry."])
138
+ grid[0][1].answer # text 0, statement 1
139
+ ```
140
+
141
+ The limits are 32 texts per call, and 20,000 characters per text. The SDK never splits a
142
+ batch for you. Splitting costs money and changes failure modes, so you decide.
143
+
144
+ ## Extraction
145
+
146
+ Four schema shapes work: a plain `dict` JSON Schema, a `TypedDict`, a dataclass, and a
147
+ pydantic v2 model. The SDK never imports pydantic. It calls `model_json_schema()` by duck
148
+ typing.
149
+
150
+ **Declare every field `| None`.** A missing value comes back as `None`.
151
+
152
+ ```python
153
+ from typing import Literal, TypedDict
154
+
155
+
156
+ class Invoice(TypedDict):
157
+ invoice_number: str | None
158
+ total: float | None
159
+ currency: Literal["USD", "EUR"] | None
160
+
161
+
162
+ data = dm.extract("Invoice 4471, total 120.00 EUR.", Invoice)
163
+ data["total"] # float | None
164
+ ```
165
+
166
+ A plain dict carries descriptions, which raise accuracy:
167
+
168
+ ```python
169
+ schema = {
170
+ "type": "object",
171
+ "properties": {
172
+ "invoice_number": {"description": "the identifier printed on the invoice"},
173
+ "total": {"type": "number", "description": "the amount due including tax"},
174
+ "tags": {"type": "array", "items": {"type": "string"}},
175
+ },
176
+ }
177
+ invoice = dm.extract("Invoice 4471, total 120.00 EUR.", schema)
178
+ ```
179
+
180
+ Four degradations are real, and no Python annotation can hide them:
181
+
182
+ - a missing value is `None`;
183
+ - an array of objects always comes back `[]`;
184
+ - an array of scalars comes back as a list of strings;
185
+ - an enum is not checked on the server, so a value outside your `Literal` can arrive.
186
+
187
+ ## Usage and rate limits
188
+
189
+ Every result carries the usage of the call that produced it. A single-text `extract` and
190
+ `post()` are the two exceptions. Both return your own object, which has no place for the
191
+ usage. Send a one-text batch to reach it: `dm.extract([text], Invoice).usage`.
192
+
193
+ ```python
194
+ r = dm.classify("I was charged twice.", LABELS)
195
+ r.usage.input_chars
196
+ r.usage.input_tokens # what this call bills
197
+ r.usage.inference_ms # model time, not wall clock
198
+ r.usage.headers["x-input-tokens"] # every response header stays reachable
199
+
200
+ limits = r.usage.rate_limit # RateLimit | None
201
+ if limits is not None:
202
+ limits.remaining_requests
203
+ limits.reset_requests # '5m0s'
204
+ ```
205
+
206
+ The rate-limit numbers come from the previous request at that Cloudflare colo. The server
207
+ accounts after the response. Read them as a trailing gauge. Never build admission control
208
+ on them.
209
+
210
+ ## Errors and retries
211
+
212
+ ```python
213
+ from milliseconds import (
214
+ AuthenticationError,
215
+ InvalidRequestError,
216
+ MillisecondsError,
217
+ OverloadedError,
218
+ QuotaExceededError,
219
+ RateLimitError,
220
+ )
221
+
222
+ try:
223
+ r = dm.classify("I was charged twice.", LABELS)
224
+ except RateLimitError as e:
225
+ print(e.retry_after, e.attempts)
226
+ except QuotaExceededError:
227
+ print("add credits at https://console.milliseconds.ai")
228
+ except MillisecondsError as e:
229
+ print(e.code, e.status, e.api_message)
230
+ ```
231
+
232
+ The SDK retries `429 rate_limit_exceeded`, `502 runner_error`, `529 overloaded`, and
233
+ transport failures. Every capability is a pure function, so a retry is always safe. It never
234
+ retries `400`, `401` or `429 insufficient_quota`. A timer retry cannot fix a spent quota.
235
+
236
+ Pass `max_retries=0` to turn retries off. `max_retries` and `timeout` also work per call:
237
+ `dm.classify(text, LABELS, max_retries=5, timeout=10.0)`.
238
+
239
+ Some checks run before any HTTP call. They raise `InvalidRequestError` with code
240
+ `client_error` and status `0`. Nothing was sent, so no token was billed.
241
+
242
+ ```python
243
+ try:
244
+ dm.classify("I was charged twice.", ["billing"])
245
+ except InvalidRequestError as e:
246
+ print(e.code) # client_error
247
+ print(e.api_message) # labels has 1 entry. classify needs 2 to 64.
248
+ ```
249
+
250
+ ## What the SDK changes, and nothing else
251
+
252
+ | Wire | SDK | Why |
253
+ | --- | --- | --- |
254
+ | `{ "results": [...] }` | a plain list | one envelope less. The order is already guaranteed. |
255
+ | `{ "entities": [...] }` | a plain list | the same |
256
+ | `{ "data": {...} }` | the object itself | the same |
257
+ | `text` / `texts` | one positional argument | the mutual exclusion becomes impossible |
258
+ | `statement` / `statements` | one positional argument | the same |
259
+ | `question` / `questions` | one positional argument | the same |
260
+ | `x-*` headers | `.usage` | the headers stay reachable, the results stay clean |
261
+
262
+ Every other field keeps its exact wire name, `snake_case` included: `when_true`,
263
+ `input_chars`, `inference_ms`, `probability`, `scores`, `start`, `end`.
264
+
265
+ `dm.post()` reaches the untouched body:
266
+
267
+ ```python
268
+ raw = dm.post("/v1/decision-machine-1/classify", {"text": "hi", "labels": ["a", "b"]})
269
+ raw["label"]
270
+ ```
271
+
272
+ ## Async, and your own pool
273
+
274
+ ```python
275
+ import asyncio
276
+
277
+ from milliseconds import AsyncDecisionMachine
278
+
279
+
280
+ async def main() -> None:
281
+ async with AsyncDecisionMachine() as adm:
282
+ r = await adm.classify("I was charged twice.", LABELS)
283
+ print(r.label)
284
+
285
+
286
+ asyncio.run(main())
287
+ ```
288
+
289
+ Both clients share one transport module, so the retries, the error parsing and the header
290
+ parsing cannot drift. Pass `http_client=` to bring your own `httpx.Client` or
291
+ `httpx.AsyncClient`. The SDK closes only a pool it opened itself.
292
+
293
+ ## Gotchas
294
+
295
+ - `yes-no` and `answer` are the two endpoints with no `text` refinement on the server. A body
296
+ with no text returns `200` and `{"results": []}`. The SDK rejects that body instead.
297
+ - A `labels` or `types` dict has no size limit on the server. The 2-to-64 rule binds the list
298
+ form only, and the SDK checks the same way.
299
+ - `classify_tree` re-sends the text at every level. The per-level `input_chars` therefore do
300
+ not sum to `usage.input_chars`, which counts one pass over the body.
301
+ - The SDK reports `x-input-tokens`. It never estimates a cost.
302
+
303
+ ## Links
304
+
305
+ - Docs: https://docs.milliseconds.ai
306
+ - Console and keys: https://console.milliseconds.ai
307
+ - The TypeScript SDK and the `dm1` CLI: `@cloudraker/milliseconds`