@cloudraker/milliseconds 0.1.0

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.
package/LICENSE ADDED
@@ -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.
package/README.md ADDED
@@ -0,0 +1,280 @@
1
+ # @cloudraker/milliseconds
2
+
3
+ Typed decisions over text. The TypeScript SDK for `decision-machine-1` at
4
+ [milliseconds.ai](https://milliseconds.ai).
5
+
6
+ `decision-machine-1` decides. It does not generate. Send text and get a yes or no, a label, a
7
+ path through a label tree, a rating, an answer span, a filled JSON Schema, entities, or a
8
+ value check. Every call is one round trip and costs input tokens only.
9
+
10
+ The SDK adds the types. Your label names, your scale levels, your entity types and your
11
+ schema flow into the **result** type. No generator can do that, so this package is hand
12
+ written and stays hand written.
13
+
14
+ ```sh
15
+ npm i @cloudraker/milliseconds # pnpm add, bun add, yarn add
16
+ export MS_API_KEY=sk-ms-... # https://console.milliseconds.ai
17
+ ```
18
+
19
+ Zero runtime dependencies. TypeScript >= 5.0. Node >= 20, Bun, Deno, browsers and Cloudflare
20
+ Workers.
21
+
22
+ ## Start here
23
+
24
+ ```ts
25
+ import { DecisionMachine } from '@cloudraker/milliseconds'
26
+
27
+ const dm = new DecisionMachine() // reads MS_API_KEY
28
+
29
+ const r = await dm.classify(ticket, {
30
+ billing: 'payments, invoices, charges and refunds',
31
+ shipping: 'delivery, tracking and packages',
32
+ account: 'login, passwords and profile settings',
33
+ })
34
+ // r: ClassifyResult<'billing' | 'shipping' | 'account'>
35
+ // r.label: 'billing' | 'shipping' | 'account'
36
+ // r.scores: Record<'billing' | 'shipping' | 'account', number>
37
+ // r.probability: number
38
+ // r.confidence: number
39
+ ```
40
+
41
+ `r.label === 'shiping'` is a compile error here. That is the whole point.
42
+
43
+ Describe every label. The description is the instruction, and the model reads it literally.
44
+ Described labels score measurably better than bare names.
45
+
46
+ ## The eight capabilities
47
+
48
+ ```ts
49
+ // 1. classify — pick one label, get the full distribution
50
+ const intent = await dm.classify(ticket, ['billing', 'shipping', 'account'])
51
+ intent.label // 'billing' | 'shipping' | 'account'
52
+
53
+ // 2. yesNo — one inference call, many statements
54
+ const [urgent, asking] = await dm.yesNo(
55
+ ticket,
56
+ ['The customer expresses urgency.', 'The customer is asking about shipping.'],
57
+ { when_true: 'Time pressure, ASAP, losing money' },
58
+ )
59
+ urgent.answer // boolean
60
+ urgent.statement // 'The customer expresses urgency.' — the literal, not string
61
+
62
+ // 3. rate — place the text on an ordered scale
63
+ const tone = await dm.rate(message, ['Calm', 'Annoyed', 'Angry', 'Threatening to leave'])
64
+ tone.score // number, 0 to 3. Route on this.
65
+ tone.level // 0 | 1 | 2 | 3
66
+ tone.label // 'Calm' | 'Annoyed' | 'Angry' | 'Threatening to leave'
67
+
68
+ // 4. answer — quote the answer out of the text, with offsets
69
+ const [who] = await dm.answer(article, ['Who announced the product?'])
70
+ if (who.answer !== null) article.slice(who.start, who.end) // start and end are numbers here
71
+
72
+ // 5. entities — every mention of every type
73
+ const found = await dm.entities(article, { person: 'a human name', place: 'a city or country' })
74
+ found[0]?.type // 'person' | 'place'
75
+
76
+ // 6. verify — check a value you already hold
77
+ const check = await dm.verify(invoiceText, 'invoice_number', 4471)
78
+ check.matches // boolean. check.found shows what the text says.
79
+
80
+ // 7. classifyTree — walk a nested taxonomy
81
+ const node = await dm.classifyTree(ticket, {
82
+ billing: {
83
+ description: 'payments, invoices, charges, refunds and subscriptions',
84
+ labels: {
85
+ refund_request: 'the customer asks for money back',
86
+ subscription_change: 'the customer wants to upgrade, downgrade or cancel a plan',
87
+ },
88
+ },
89
+ shipping: 'delivery, tracking, lost or damaged parcels',
90
+ })
91
+ node.label // 'refund_request' | 'subscription_change' | 'shipping' — a walk stops here
92
+ node.path // every label on the way down
93
+
94
+ // 8. extract — fill a JSON Schema
95
+ const data = await dm.extract(letter, {
96
+ type: 'object',
97
+ properties: {
98
+ due_date: { description: 'the date the payment is due' },
99
+ total: { type: 'number' },
100
+ },
101
+ })
102
+ data.total // number | null
103
+ ```
104
+
105
+ ## Batching
106
+
107
+ One text gives one result. A tuple of texts gives a tuple of results, in order.
108
+
109
+ ```ts
110
+ const one = await dm.classify(ticket, LABELS) // ClassifyResult<Intent>
111
+ const [a, b] = await dm.classify([t1, t2], LABELS) // a tuple of two
112
+ const many = await dm.classify(tickets, LABELS) // tickets: string[] -> ClassifyResult<Intent>[]
113
+
114
+ // Both axes at once: texts times statements.
115
+ const grid = await dm.yesNo([t1, t2], ['The text mentions a price.'])
116
+ // grid[0][0].answer
117
+ ```
118
+
119
+ A hoisted label list needs `as const` to keep its union: `const LABELS = ['billing',
120
+ 'shipping'] as const`. A plain `string[]`, read from a config file for example, types
121
+ `r.label` as `string`. The literal inline forms above need nothing.
122
+
123
+ A batch holds at most 32 texts, and each text at most 20,000 characters. The SDK never
124
+ chunks for you: chunking costs money and changes failure modes, so the caller decides.
125
+ `dm1 --lines` chunks, and says so.
126
+
127
+ ## Extraction
128
+
129
+ A JSON Schema object literal types the result on its own.
130
+
131
+ ```ts
132
+ const data = await dm.extract(letter, {
133
+ type: 'object',
134
+ properties: {
135
+ reference: { type: 'string', enum: ['AB', 'CD'] },
136
+ tags: { type: 'array', items: { type: 'string' } },
137
+ line_items: { type: 'array', items: { type: 'object' } },
138
+ vendor: { type: 'object', properties: { name: { type: 'string' } } },
139
+ },
140
+ })
141
+ // reference: 'AB' | 'CD' | (string & {}) | null
142
+ // tags: string[] | null
143
+ // line_items: never[]
144
+ // vendor: { name: string | null }
145
+ ```
146
+
147
+ Four degradations are real, and the types state them:
148
+
149
+ 1. A missing value is `null`. Every scalar leaf is nullable.
150
+ 2. An array of objects always comes back `[]`. Line-item quality is not good enough to ship.
151
+ 3. An array of scalars comes back as strings. The runner calls `String()` on every element.
152
+ 4. An enum is not checked server side. `(string & {})` admits reality and keeps autocomplete.
153
+
154
+ A nested object is never `null`. Only its leaves are.
155
+
156
+ zod and valibot need one line, because the SDK carries no dependency that converts them:
157
+
158
+ ```ts
159
+ import { z } from 'zod'
160
+ import { typed } from '@cloudraker/milliseconds'
161
+
162
+ const Invoice = z.object({ total: z.number(), currency: z.enum(['USD', 'EUR']) })
163
+ const data = await dm.extract(pdfText, typed<z.infer<typeof Invoice>>(z.toJSONSchema(Invoice)))
164
+ // data.total: number | null
165
+ ```
166
+
167
+ zod 4.2 and later give every schema its own `toJSONSchema()` method, so
168
+ `dm.extract(pdfText, Invoice)` works too and infers the same type. `typed<>` stays right for
169
+ valibot and older zod, where the converter is a module function.
170
+
171
+ An arktype schema passes straight in: `dm.extract(text, Invoice)`. The SDK calls its
172
+ `toJsonSchema()` and reads the output type from `~standard`.
173
+
174
+ ## Usage and rate limits
175
+
176
+ ```ts
177
+ const { result, usage, response } = await dm.classify(ticket, LABELS).withUsage()
178
+ usage.inputTokens // what this call bills
179
+ usage.inferenceMs // model time, summed over the calls this request made
180
+ usage.rateLimit // RateLimit | null
181
+ usage.headers // every response header
182
+ ```
183
+
184
+ The rate-limit numbers come from the previous request at that Cloudflare colo. The API
185
+ accounts after the response. Read them as a trailing gauge. Never build admission control on
186
+ them.
187
+
188
+ ## Errors and retries
189
+
190
+ ```ts
191
+ import { isMillisecondsError } from '@cloudraker/milliseconds'
192
+
193
+ try {
194
+ await dm.classify(ticket, LABELS)
195
+ } catch (e) {
196
+ if (!isMillisecondsError(e)) throw e
197
+ switch (e.code) {
198
+ case 'rate_limit_exceeded':
199
+ return wait(e.retryAfter) // seconds, or null
200
+ case 'insufficient_quota':
201
+ return topUp() // never retried: a timer will not help
202
+ case 'invalid_request':
203
+ return fix(e.apiMessage) // the wire text, unchanged
204
+ default:
205
+ throw e
206
+ }
207
+ }
208
+ ```
209
+
210
+ The SDK retries `429 rate_limit_exceeded`, `502 runner_error`, `529 overloaded`, and
211
+ transport failures and timeouts. Every capability is a pure function, so a retry is always
212
+ safe. It never retries `400`, `401` or `429 insufficient_quota`. `maxRetries` defaults to 2
213
+ and takes `0`. The backoff is full jitter, capped at 8 seconds, and a `retry-after` header
214
+ wins over the backoff.
215
+
216
+ `e.attempts` counts the attempts, including the first. `e.status` is `0` when the call never
217
+ reached the API.
218
+
219
+ A `502`, `503` or `504` with no JSON body is retried on the status alone. A Cloudflare error
220
+ page never reaches the worker, so it carries no code.
221
+
222
+ The SDK also checks your call before it sends anything: the label, statement, scale and text
223
+ limits, and the two API traps. Those throw `code: 'client_error'` with `status: 0`, and no
224
+ token is billed. They throw **synchronously**, before the `Decision` exists, so catch them
225
+ with `try`/`catch` around the call, not with `.catch()` on it.
226
+
227
+ ## What the SDK changes, and nothing else
228
+
229
+ | Wire | SDK | Why |
230
+ | --- | --- | --- |
231
+ | `{ results: [...] }` | a plain array | one envelope less. The order is already guaranteed. |
232
+ | `{ entities: [...] }` | a plain array | the same |
233
+ | `{ data: {...} }` | the object itself | the same |
234
+ | `text` / `texts` | one positional `input` | the mutual exclusion becomes impossible |
235
+ | `statement` / `statements` | one positional argument | the same |
236
+ | `question` / `questions` | one positional argument | the same |
237
+ | `x-*` headers | `withUsage()` | the headers stay reachable, the results stay clean |
238
+
239
+ Every other field keeps its exact wire name, `snake_case` included: `when_true`,
240
+ `input_chars`, `inference_ms`, `probability`, `scores`, `start`, `end`.
241
+
242
+ `dm.post()` reaches the untouched body, and any future path:
243
+
244
+ ```ts
245
+ const raw = await dm.post<unknown>('/v1/decision-machine-1/classify', { text, labels })
246
+ ```
247
+
248
+ ## Runtimes
249
+
250
+ Node >= 20, Bun and Deno work with no configuration. The SDK uses global `fetch` and ships
251
+ ESM, CJS and `.d.ts`.
252
+
253
+ On Cloudflare Workers, pass a service binding's `fetch`:
254
+
255
+ ```ts
256
+ const dm = new DecisionMachine({ apiKey: env.MS_API_KEY, fetch: env.MILLISECONDS.fetch.bind(env.MILLISECONDS) })
257
+ ```
258
+
259
+ In a browser the constructor throws. Your API key is a secret, and a bundle ships it to every
260
+ visitor. Call the API from your server. `dangerouslyAllowBrowser: true` opts out, and is
261
+ right only when the bundle never reaches a user.
262
+
263
+ ## Gotchas
264
+
265
+ - `yes-no` answers `200` with `{"results":[]}` for a body that carries neither `text` nor
266
+ `texts`. The SDK always sends one of the two, so that body cannot reach the API. An empty
267
+ text is a `400`, and the SDK's local check only saves you the round trip.
268
+ - `classify-tree` sums `inference_ms` over every level into the header, while `x-input-chars`
269
+ counts one pass over the body. The per-level numbers do not sum to `usage.inputChars`.
270
+ - `retry-after` rides on `429 rate_limit_exceeded` only. `e.retryAfter` is `null` on
271
+ `insufficient_quota`.
272
+ - The six `x-ratelimit-*` headers arrive together or not at all. `usage.rateLimit` is `null`
273
+ in the second case.
274
+
275
+ ## Links
276
+
277
+ - Docs: [docs.milliseconds.ai](https://docs.milliseconds.ai)
278
+ - Console and keys: [console.milliseconds.ai](https://console.milliseconds.ai)
279
+ - Python: `pip install cloudraker-milliseconds`
280
+ - CLI: `npm i -g @cloudraker/milliseconds` then `dm1 --help`