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.
- cloudraker_milliseconds-0.1.1/.gitignore +9 -0
- cloudraker_milliseconds-0.1.1/LICENSE +21 -0
- cloudraker_milliseconds-0.1.1/PKG-INFO +328 -0
- cloudraker_milliseconds-0.1.1/README.md +307 -0
- cloudraker_milliseconds-0.1.1/pyproject.toml +63 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/__init__.py +66 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_client.py +693 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_errors.py +190 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_models.py +205 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_schema.py +154 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_transport.py +166 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/_validate.py +121 -0
- cloudraker_milliseconds-0.1.1/src/milliseconds/py.typed +0 -0
- cloudraker_milliseconds-0.1.1/tests/conftest.py +166 -0
- cloudraker_milliseconds-0.1.1/tests/test_client.py +547 -0
- cloudraker_milliseconds-0.1.1/tests/test_live.py +171 -0
- cloudraker_milliseconds-0.1.1/tests/test_readme.py +53 -0
- cloudraker_milliseconds-0.1.1/tests/test_types.py +121 -0
- cloudraker_milliseconds-0.1.1/tests/test_validate.py +211 -0
- cloudraker_milliseconds-0.1.1/uv.lock +467 -0
|
@@ -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`
|