@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 +21 -0
- package/README.md +280 -0
- package/dist/cli.js +1402 -0
- package/dist/index.cjs +456 -0
- package/dist/index.d.cts +330 -0
- package/dist/index.d.ts +330 -0
- package/dist/index.js +452 -0
- package/package.json +76 -0
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`
|