@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/dist/cli.js
ADDED
|
@@ -0,0 +1,1402 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
//#region src/errors.ts
|
|
6
|
+
/** A brand property, so a duplicated copy of the package in a bundle still matches. */
|
|
7
|
+
const BRAND = "~milliseconds.error";
|
|
8
|
+
/**
|
|
9
|
+
* One error class. Switch on `code`: the union narrows exhaustively and never goes stale
|
|
10
|
+
* when the API adds a code.
|
|
11
|
+
*/
|
|
12
|
+
var MillisecondsError = class extends Error {
|
|
13
|
+
name = "MillisecondsError";
|
|
14
|
+
[BRAND] = true;
|
|
15
|
+
code;
|
|
16
|
+
/** 0 when the call never reached the API. */
|
|
17
|
+
status;
|
|
18
|
+
/** The API's own message, unchanged. `message` adds one hint line. */
|
|
19
|
+
apiMessage;
|
|
20
|
+
/** Seconds from the retry-after header. Only 429 rate_limit_exceeded carries it. */
|
|
21
|
+
retryAfter;
|
|
22
|
+
rateLimit;
|
|
23
|
+
response;
|
|
24
|
+
/** Attempts this call made, including the first. */
|
|
25
|
+
attempts;
|
|
26
|
+
/** True for the codes the SDK retries. */
|
|
27
|
+
retryable;
|
|
28
|
+
constructor(init) {
|
|
29
|
+
super(init.message ?? init.apiMessage, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
30
|
+
this.code = init.code;
|
|
31
|
+
this.status = init.status ?? 0;
|
|
32
|
+
this.apiMessage = init.apiMessage;
|
|
33
|
+
this.retryAfter = init.retryAfter ?? null;
|
|
34
|
+
this.rateLimit = init.rateLimit ?? null;
|
|
35
|
+
this.response = init.response ?? null;
|
|
36
|
+
this.attempts = init.attempts ?? 1;
|
|
37
|
+
this.retryable = init.retryable ?? false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
function isMillisecondsError(e) {
|
|
41
|
+
return typeof e === "object" && e !== null && e[BRAND] === true;
|
|
42
|
+
}
|
|
43
|
+
/** Every client-side check throws this: status 0, code client_error, nothing sent. */
|
|
44
|
+
const clientError = (message) => new MillisecondsError({
|
|
45
|
+
code: "client_error",
|
|
46
|
+
status: 0,
|
|
47
|
+
apiMessage: message
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/validate.ts
|
|
51
|
+
const MAX_CHARS = 2e4;
|
|
52
|
+
const MAX_TEXTS = 32;
|
|
53
|
+
const MAX_ITEMS = 32;
|
|
54
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
55
|
+
const count = (size) => size === 1 ? "1 entry" : `${n(size)} entries`;
|
|
56
|
+
/** The key is a secret, so a browser bundle needs an explicit opt-in. */
|
|
57
|
+
function checkRuntime(apiKey, allowBrowser) {
|
|
58
|
+
if (!apiKey) throw clientError("No API key. Pass new DecisionMachine({ apiKey }) or set MS_API_KEY. Get a key at https://console.milliseconds.ai.");
|
|
59
|
+
if (typeof globalThis === "object" && typeof globalThis.document?.createElement === "function" && !allowBrowser) throw clientError("The API key is a secret. Call the API from your server, or pass dangerouslyAllowBrowser: true when the bundle never reaches a user.");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `texts` is 1 to 32 items, and every text is 1 to 20,000 characters.
|
|
63
|
+
*
|
|
64
|
+
* An empty text is a 400, not the 200-with-empty-results trap. That trap needs `text` and
|
|
65
|
+
* `texts` both absent, and the SDK always sends one of them. This check only replaces a
|
|
66
|
+
* round trip with a local error.
|
|
67
|
+
*/
|
|
68
|
+
function checkInput(input) {
|
|
69
|
+
if (typeof input === "string") {
|
|
70
|
+
if (input === "") throw empty("text");
|
|
71
|
+
if (input.length > MAX_CHARS) throw tooLong("text", input.length);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (input.length === 0) throw clientError("texts is empty. Send at least one text.");
|
|
75
|
+
if (input.length > MAX_TEXTS) throw clientError(`texts has ${n(input.length)} items. The limit is ${n(MAX_TEXTS)}. Split the batch.`);
|
|
76
|
+
for (const [i, text] of input.entries()) {
|
|
77
|
+
if (text === "") throw empty(`texts[${i}]`);
|
|
78
|
+
if (text.length > MAX_CHARS) throw tooLong(`texts[${i}]`, text.length);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const empty = (where) => clientError(`${where} is empty. Send at least one character.`);
|
|
82
|
+
const tooLong = (where, length) => clientError(`${where} is ${n(length)} characters. The limit is ${n(MAX_CHARS)}. Split on paragraphs and send the parts as texts.`);
|
|
83
|
+
/**
|
|
84
|
+
* `statements` and `questions` are 1 to 32 items. The wire message for a body without one
|
|
85
|
+
* is `provide statement or statements, not both`, which names both fields and misleads.
|
|
86
|
+
*/
|
|
87
|
+
function checkSpec(name, value) {
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
if (value !== "") return;
|
|
90
|
+
if (name === "statements") throw clientError("yes-no needs a statement. The wire message for a body without one names both fields and misleads.");
|
|
91
|
+
throw clientError(`${name} has 0 items. Send 1 to ${MAX_ITEMS}.`);
|
|
92
|
+
}
|
|
93
|
+
if (value.length === 0 || value.length > MAX_ITEMS) throw clientError(`${name} has ${n(value.length)} items. Send 1 to ${MAX_ITEMS}.`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* `labels` is 2 to 64, `types` is 1 to 64 and `scale` is 2 to 10. Three limits, because
|
|
97
|
+
* `decide.schema.ts` holds three. One shared rule would reject a legal single-type
|
|
98
|
+
* `entities` call.
|
|
99
|
+
*
|
|
100
|
+
* `decide.schema.ts` bounds the array branch of `labels` and `types` only: the
|
|
101
|
+
* `name -> description` branch is a plain `z.record`, so any number of described labels is
|
|
102
|
+
* legal. `classify-tree` passes `bounded` because its own `superRefine` bounds every level.
|
|
103
|
+
*/
|
|
104
|
+
function checkList(name, value, min, max, capability, bounded = Array.isArray(value)) {
|
|
105
|
+
const size = Array.isArray(value) ? value.length : Object.keys(value).length;
|
|
106
|
+
if (bounded ? size >= min && size <= max : size > 0) return;
|
|
107
|
+
const found = size === 0 ? `${name} is empty.` : `${name} has ${count(size)}.`;
|
|
108
|
+
throw clientError(`${found} ${capability} needs ${min} to ${max}.`);
|
|
109
|
+
}
|
|
110
|
+
/** `planFor` throws unless the schema is an object with properties. Beat it locally. */
|
|
111
|
+
function checkSchema(schema) {
|
|
112
|
+
const s = schema;
|
|
113
|
+
if ((Array.isArray(s?.type) ? s.type.find((t) => t !== "null") : s?.type) !== "object" || typeof s?.properties !== "object" || s.properties === null) throw clientError("The schema must be an object with properties.");
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/client.ts
|
|
117
|
+
const USER_AGENT = `cloudraker-milliseconds-js/0.1.0`;
|
|
118
|
+
const DEFAULT_BASE_URL = "https://api.milliseconds.ai";
|
|
119
|
+
const RETRY = /* @__PURE__ */ new Set([
|
|
120
|
+
"rate_limit_exceeded",
|
|
121
|
+
"runner_error",
|
|
122
|
+
"overloaded",
|
|
123
|
+
"connection_error",
|
|
124
|
+
"timeout"
|
|
125
|
+
]);
|
|
126
|
+
/** Full jitter, capped at 8 s. */
|
|
127
|
+
const backoff = (n) => Math.random() * Math.min(500 * 2 ** n, 8e3);
|
|
128
|
+
/** Resolves early when the caller aborts. The loop then throws the caller's reason. */
|
|
129
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
130
|
+
const timer = setTimeout(resolve, ms);
|
|
131
|
+
signal?.addEventListener("abort", () => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve();
|
|
134
|
+
}, { once: true });
|
|
135
|
+
});
|
|
136
|
+
/** Reads an environment variable without assuming `process` exists. */
|
|
137
|
+
function env(name) {
|
|
138
|
+
try {
|
|
139
|
+
return globalThis.process?.env?.[name];
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** A Promise of the result, with the headers one call away. No Promise subclass. */
|
|
145
|
+
const decision = (p) => {
|
|
146
|
+
const result = p.then((r) => r.result);
|
|
147
|
+
return Object.assign(result, { withUsage: () => {
|
|
148
|
+
result.catch(() => {});
|
|
149
|
+
return p;
|
|
150
|
+
} });
|
|
151
|
+
};
|
|
152
|
+
function rateLimitOf(h) {
|
|
153
|
+
const limitRequests = h.get("x-ratelimit-limit-requests");
|
|
154
|
+
if (limitRequests === null) return null;
|
|
155
|
+
const num = (k) => Number(h.get(k)) || 0;
|
|
156
|
+
return {
|
|
157
|
+
limitRequests: Number(limitRequests) || 0,
|
|
158
|
+
remainingRequests: num("x-ratelimit-remaining-requests"),
|
|
159
|
+
resetRequests: h.get("x-ratelimit-reset-requests") ?? "",
|
|
160
|
+
limitTokens: num("x-ratelimit-limit-tokens"),
|
|
161
|
+
remainingTokens: num("x-ratelimit-remaining-tokens"),
|
|
162
|
+
resetTokens: h.get("x-ratelimit-reset-tokens") ?? ""
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function usageOf(response) {
|
|
166
|
+
const h = response.headers;
|
|
167
|
+
const num = (k) => Number(h.get(k)) || 0;
|
|
168
|
+
return {
|
|
169
|
+
inputChars: num("x-input-chars"),
|
|
170
|
+
inputTokens: num("x-input-tokens"),
|
|
171
|
+
inferenceMs: num("x-inference-ms"),
|
|
172
|
+
rateLimit: rateLimitOf(h),
|
|
173
|
+
headers: h
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The hint line under the API message. Section 8.2 of DESIGN.md holds the copy. */
|
|
177
|
+
function describe(code, status, apiMessage, attempts, ms) {
|
|
178
|
+
const over = attempts > 1 ? ` after ${attempts} attempts over ${(ms / 1e3).toFixed(1)}s` : "";
|
|
179
|
+
const tail = apiMessage ? `:\n${apiMessage}` : ".";
|
|
180
|
+
switch (code) {
|
|
181
|
+
case "missing_api_key":
|
|
182
|
+
case "invalid_api_key": return `milliseconds rejected the API key (${status} ${code}).\nKeys start with "sk-ms-". Check MS_API_KEY, or create a key at\nhttps://console.milliseconds.ai.`;
|
|
183
|
+
case "rate_limit_exceeded": return `rate limited (${status} ${code})${over}${tail}\n Lower your concurrency, or raise maxRetries. Limits are per organization and\n shared by every key.`;
|
|
184
|
+
case "insufficient_quota": return `no token credits left (${status} ${code})${tail}\n Not retried. A timer retry will not help.`;
|
|
185
|
+
case "overloaded": return `every inference slot stayed busy (${status} ${code})${over}${tail}\n This is backpressure, not a fault. Send fewer texts per call, or back off further.\n A texts batch of 32 asks for 32 slots at once.`;
|
|
186
|
+
case "runner_error": return `inference failed twice (${status} ${code})${over}${tail}\n The model, not your request. Retry later, or raise maxRetries.`;
|
|
187
|
+
case "timeout": return `the request timed out${over}${tail}\n Raise timeout, or send fewer texts per call.`;
|
|
188
|
+
case "connection_error": return `could not reach the API${over}${tail}\n Check baseUrl and the network. maxRetries: 0 fails fast.`;
|
|
189
|
+
default: return `milliseconds rejected the request (${status} ${code})${tail}${apiMessage.startsWith("labels:") ? "\n classify needs two or more labels. Describe each one. Described labels score\n measurably better than bare names." : ""}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** One AbortSignal for the attempt: the per-attempt timeout, plus the caller's signal. */
|
|
193
|
+
function attemptSignal(timeout, signal) {
|
|
194
|
+
const hasAny = typeof AbortSignal.any === "function";
|
|
195
|
+
if (typeof AbortSignal.timeout === "function" && (!signal || hasAny)) {
|
|
196
|
+
const t = AbortSignal.timeout(timeout);
|
|
197
|
+
return {
|
|
198
|
+
signal: signal ? AbortSignal.any([signal, t]) : t,
|
|
199
|
+
done: () => {}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeout);
|
|
204
|
+
signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
205
|
+
return {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
done: () => clearTimeout(timer)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** Transport: fetch, retries, errors and usage. Every capability is two lines on top. */
|
|
211
|
+
var Client = class {
|
|
212
|
+
baseUrl;
|
|
213
|
+
#apiKey;
|
|
214
|
+
#timeout;
|
|
215
|
+
#maxRetries;
|
|
216
|
+
#headers;
|
|
217
|
+
#fetch;
|
|
218
|
+
constructor(options = {}) {
|
|
219
|
+
const apiKey = options.apiKey ?? env("MS_API_KEY");
|
|
220
|
+
checkRuntime(apiKey, options.dangerouslyAllowBrowser);
|
|
221
|
+
this.#apiKey = apiKey;
|
|
222
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
223
|
+
this.#timeout = options.timeout ?? 6e4;
|
|
224
|
+
this.#maxRetries = options.maxRetries ?? 2;
|
|
225
|
+
this.#headers = { ...options.headers };
|
|
226
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
227
|
+
}
|
|
228
|
+
/** Escape hatch. Your path, your body, your type, the SDK's auth, retries and errors. */
|
|
229
|
+
post(path, body, options) {
|
|
230
|
+
return this.call(path, body, options, (raw) => raw);
|
|
231
|
+
}
|
|
232
|
+
/** `unwrap` maps the parsed body to the result. It follows the request, never the response. */
|
|
233
|
+
call(path, body, options, unwrap) {
|
|
234
|
+
return decision(this.send(path, body, options ?? {}).then(({ response, raw }) => ({
|
|
235
|
+
result: unwrap(raw),
|
|
236
|
+
usage: usageOf(response),
|
|
237
|
+
response
|
|
238
|
+
})));
|
|
239
|
+
}
|
|
240
|
+
async send(path, body, call) {
|
|
241
|
+
const url = `${this.baseUrl}${path}`;
|
|
242
|
+
const maxRetries = call.maxRetries ?? this.#maxRetries;
|
|
243
|
+
const timeout = call.timeout ?? this.#timeout;
|
|
244
|
+
const headers = new Headers({
|
|
245
|
+
"content-type": "application/json",
|
|
246
|
+
"user-agent": USER_AGENT
|
|
247
|
+
});
|
|
248
|
+
for (const [k, v] of Object.entries({
|
|
249
|
+
...this.#headers,
|
|
250
|
+
...call.headers
|
|
251
|
+
})) headers.set(k, v);
|
|
252
|
+
headers.set("authorization", `Bearer ${this.#apiKey}`);
|
|
253
|
+
const init = {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers,
|
|
256
|
+
body: JSON.stringify(body)
|
|
257
|
+
};
|
|
258
|
+
const started = Date.now();
|
|
259
|
+
for (let attempt = 1;; attempt++) {
|
|
260
|
+
if (call.signal?.aborted) throw call.signal.reason;
|
|
261
|
+
const outcome = await this.attempt(url, init, timeout, call.signal, attempt, started);
|
|
262
|
+
if (!(outcome instanceof MillisecondsError)) return outcome;
|
|
263
|
+
if (!outcome.retryable || attempt > maxRetries) throw outcome;
|
|
264
|
+
const wait = outcome.retryAfter === null ? backoff(attempt - 1) : Math.min(outcome.retryAfter, 60) * 1e3;
|
|
265
|
+
await sleep(wait, call.signal);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async attempt(url, init, timeout, signal, attempt, started) {
|
|
269
|
+
const guard = attemptSignal(timeout, signal);
|
|
270
|
+
try {
|
|
271
|
+
const response = await this.#fetch(url, {
|
|
272
|
+
...init,
|
|
273
|
+
signal: guard.signal
|
|
274
|
+
});
|
|
275
|
+
if (response.ok) return {
|
|
276
|
+
response,
|
|
277
|
+
raw: await response.json()
|
|
278
|
+
};
|
|
279
|
+
return await httpError(response, attempt, Date.now() - started);
|
|
280
|
+
} catch (cause) {
|
|
281
|
+
if (signal?.aborted) throw cause;
|
|
282
|
+
return transportError(cause, attempt, Date.now() - started);
|
|
283
|
+
} finally {
|
|
284
|
+
guard.done();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
function transportError(cause, attempts, ms) {
|
|
289
|
+
const name = cause?.name ?? "";
|
|
290
|
+
const code = name === "TimeoutError" || name === "AbortError" ? "timeout" : "connection_error";
|
|
291
|
+
const apiMessage = cause?.message ?? String(cause);
|
|
292
|
+
return new MillisecondsError({
|
|
293
|
+
code,
|
|
294
|
+
status: 0,
|
|
295
|
+
apiMessage,
|
|
296
|
+
message: describe(code, 0, apiMessage, attempts, ms),
|
|
297
|
+
attempts,
|
|
298
|
+
retryable: true,
|
|
299
|
+
cause
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async function httpError(response, attempts, ms) {
|
|
303
|
+
const body = await response.clone().json().catch(() => null);
|
|
304
|
+
const code = body?.error?.code ?? (response.status >= 500 ? "internal_error" : "http_error");
|
|
305
|
+
const apiMessage = body?.error?.message ?? response.statusText;
|
|
306
|
+
const retryAfter = response.headers.get("retry-after");
|
|
307
|
+
return new MillisecondsError({
|
|
308
|
+
code,
|
|
309
|
+
status: response.status,
|
|
310
|
+
apiMessage,
|
|
311
|
+
message: describe(code, response.status, apiMessage, attempts, ms),
|
|
312
|
+
retryAfter: retryAfter === null ? null : Number(retryAfter) || 0,
|
|
313
|
+
rateLimit: rateLimitOf(response.headers),
|
|
314
|
+
response,
|
|
315
|
+
attempts,
|
|
316
|
+
retryable: RETRY.has(code) || response.status >= 502 && response.status <= 504
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/schema.ts
|
|
321
|
+
const CONVERT = "Pass a JSON Schema. zod: typed<z.infer<typeof S>>(z.toJSONSchema(S)). valibot: typed<v.InferOutput<typeof S>>(toJsonSchema(S)). arktype works directly.";
|
|
322
|
+
/**
|
|
323
|
+
* The object to send. A Standard Schema with a converter method (arktype) is converted.
|
|
324
|
+
* A Standard Schema without one cannot be: the SDK has zero dependencies, so it names the
|
|
325
|
+
* one line that converts it instead of importing zod.
|
|
326
|
+
*/
|
|
327
|
+
function toJsonSchema(schema) {
|
|
328
|
+
const s = schema;
|
|
329
|
+
const convert = typeof s.toJsonSchema === "function" ? s.toJsonSchema : s.toJSONSchema;
|
|
330
|
+
if (typeof convert === "function") return convert.call(s);
|
|
331
|
+
if ("~standard" in s) throw new MillisecondsError({
|
|
332
|
+
code: "invalid_schema",
|
|
333
|
+
status: 0,
|
|
334
|
+
apiMessage: CONVERT
|
|
335
|
+
});
|
|
336
|
+
return s;
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/decision-machine.ts
|
|
340
|
+
const MODEL = "decision-machine-1";
|
|
341
|
+
/** The batch shape follows the request, never the response. */
|
|
342
|
+
const same = (raw) => raw;
|
|
343
|
+
const key = (name) => (raw) => raw[name];
|
|
344
|
+
/** `text` for one result, `texts` for a batch. The mutual exclusion is impossible here. */
|
|
345
|
+
const inputBody = (input) => typeof input === "string" ? { text: input } : { texts: input };
|
|
346
|
+
/**
|
|
347
|
+
* `decision-machine-1` at api.milliseconds.ai. Every capability is a pure function, so
|
|
348
|
+
* every retry is safe.
|
|
349
|
+
*/
|
|
350
|
+
var DecisionMachine = class extends Client {
|
|
351
|
+
/** Paths are `${baseUrl}/v1/${model}/<capability>`. */
|
|
352
|
+
static model = MODEL;
|
|
353
|
+
/**
|
|
354
|
+
* Answers each statement with yes or no and a probability. The statements share one
|
|
355
|
+
* inference call, so extra statements are nearly free.
|
|
356
|
+
*/
|
|
357
|
+
yesNo(input, statements, options) {
|
|
358
|
+
checkInput(input);
|
|
359
|
+
checkSpec("statements", statements);
|
|
360
|
+
const { when_true, when_false, ...call } = options ?? {};
|
|
361
|
+
const body = {
|
|
362
|
+
...inputBody(input),
|
|
363
|
+
...typeof statements === "string" ? { statement: statements } : { statements },
|
|
364
|
+
...when_true === void 0 ? {} : { when_true },
|
|
365
|
+
...when_false === void 0 ? {} : { when_false }
|
|
366
|
+
};
|
|
367
|
+
return this.capability("yes-no", body, input, call, typeof statements === "string" ? same : key("results"));
|
|
368
|
+
}
|
|
369
|
+
/** Picks one label and returns the full distribution. Describe each label. */
|
|
370
|
+
classify(input, labels, options) {
|
|
371
|
+
checkInput(input);
|
|
372
|
+
checkList("labels", labels, 2, 64, "classify");
|
|
373
|
+
return this.capability("classify", {
|
|
374
|
+
...inputBody(input),
|
|
375
|
+
labels
|
|
376
|
+
}, input, options, same);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Runs classify once per level of a nested tree, descending into the winner.
|
|
380
|
+
*
|
|
381
|
+
* The per-level `input_chars` and `input_tokens` do not sum to `Usage.inputChars`: the
|
|
382
|
+
* header counts one pass over the body, and each level re-sends the text.
|
|
383
|
+
*/
|
|
384
|
+
classifyTree(input, tree, options) {
|
|
385
|
+
checkInput(input);
|
|
386
|
+
checkList("labels", tree, 2, 64, "classify-tree", true);
|
|
387
|
+
return this.capability("classify-tree", {
|
|
388
|
+
...inputBody(input),
|
|
389
|
+
tree
|
|
390
|
+
}, input, options, same);
|
|
391
|
+
}
|
|
392
|
+
/** Places the text on an ordered scale of described levels, low to high. */
|
|
393
|
+
rate(input, scale, options) {
|
|
394
|
+
checkInput(input);
|
|
395
|
+
checkList("scale", scale, 2, 10, "rate");
|
|
396
|
+
return this.capability("rate", {
|
|
397
|
+
...inputBody(input),
|
|
398
|
+
scale
|
|
399
|
+
}, input, options, same);
|
|
400
|
+
}
|
|
401
|
+
/** Quotes the answer out of the text, with its offsets. `answer` is null when nothing fits. */
|
|
402
|
+
answer(input, questions, options) {
|
|
403
|
+
checkInput(input);
|
|
404
|
+
checkSpec("questions", questions);
|
|
405
|
+
const body = {
|
|
406
|
+
...inputBody(input),
|
|
407
|
+
...typeof questions === "string" ? { question: questions } : { questions }
|
|
408
|
+
};
|
|
409
|
+
return this.capability("answer", body, input, options, typeof questions === "string" ? same : key("results"));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Fills a JSON Schema from the text. Missing values are null, arrays of objects come back
|
|
413
|
+
* empty, arrays of scalars come back as strings, and enums are not checked server side.
|
|
414
|
+
*/
|
|
415
|
+
extract(input, schema, options) {
|
|
416
|
+
checkInput(input);
|
|
417
|
+
const json = toJsonSchema(schema);
|
|
418
|
+
checkSchema(json);
|
|
419
|
+
return this.capability("extract", {
|
|
420
|
+
...inputBody(input),
|
|
421
|
+
schema: json
|
|
422
|
+
}, input, options, key("data"));
|
|
423
|
+
}
|
|
424
|
+
/** Finds every span matching each type, with offsets, sorted by start. */
|
|
425
|
+
entities(input, types, options) {
|
|
426
|
+
checkInput(input);
|
|
427
|
+
checkList("types", types, 1, 64, "entities");
|
|
428
|
+
return this.capability("entities", {
|
|
429
|
+
...inputBody(input),
|
|
430
|
+
types
|
|
431
|
+
}, input, options, key("entities"));
|
|
432
|
+
}
|
|
433
|
+
/** Checks whether the text says `value` for `field`. */
|
|
434
|
+
verify(input, field, value, options) {
|
|
435
|
+
checkInput(input);
|
|
436
|
+
const body = {
|
|
437
|
+
...inputBody(input),
|
|
438
|
+
field: typeof field === "string" ? { name: field } : field,
|
|
439
|
+
value
|
|
440
|
+
};
|
|
441
|
+
return this.capability("verify", body, input, options, same);
|
|
442
|
+
}
|
|
443
|
+
/** `{ results }` is unwrapped for a batch, and the per-text envelope for one text. */
|
|
444
|
+
capability(name, body, input, options, one) {
|
|
445
|
+
const unwrap = typeof input === "string" ? one : (raw) => (raw.results ?? []).map(one);
|
|
446
|
+
return this.call(`/v1/${MODEL}/${name}`, body, options, unwrap);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/cli/print.ts
|
|
451
|
+
const num = (r, k) => Number(r[k] ?? 0);
|
|
452
|
+
const text = (r, k) => String(r[k] ?? "");
|
|
453
|
+
const p3 = (n) => n.toFixed(3);
|
|
454
|
+
const int = (n) => n.toLocaleString("en-US");
|
|
455
|
+
/** ANSI off for --no-color, for NO_COLOR, and for a pipe. */
|
|
456
|
+
const dimmer = (color) => (s) => color ? `\u001B[2m${s}\u001B[0m` : s;
|
|
457
|
+
/** `label billing` — a padded key column. */
|
|
458
|
+
const kv = (rows, dim, width = 12) => rows.map(([k, v]) => `${dim(k.padEnd(width))} ${v}`).join("\n");
|
|
459
|
+
/** 20 blocks at 1.0. 0.001 rounds to none, so a flat score prints no bar. */
|
|
460
|
+
const bar = (p) => "█".repeat(Math.round(p * 20));
|
|
461
|
+
/** The scores block under a table. `sort` orders by score; a scale keeps its own order. */
|
|
462
|
+
function scores(entries, dim, sort) {
|
|
463
|
+
const rows = sort ? [...entries].sort((a, b) => b[1] - a[1]) : entries;
|
|
464
|
+
const width = Math.max(0, ...rows.map(([name]) => name.length)) + 3;
|
|
465
|
+
const body = rows.map(([name, p]) => {
|
|
466
|
+
const drawn = bar(p);
|
|
467
|
+
return ` ${name.padEnd(width)}${p3(p)}${drawn ? ` ${drawn}` : ""}`;
|
|
468
|
+
});
|
|
469
|
+
return [dim("scores"), ...body].join("\n");
|
|
470
|
+
}
|
|
471
|
+
/** An aligned block of columns, two spaces apart. */
|
|
472
|
+
function columns(rows) {
|
|
473
|
+
const width = [];
|
|
474
|
+
for (const row of rows) row.forEach((cell, i) => width[i] = Math.max(width[i] ?? 0, cell.length));
|
|
475
|
+
return rows.map((row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(width[i] ?? 0)).join(" ").trimEnd()).join("\n");
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/cli/spec.ts
|
|
479
|
+
/** Bad arguments or bad stdin. Nothing was sent, so `dm1` exits 2. */
|
|
480
|
+
var UsageError = class extends Error {};
|
|
481
|
+
const specSize = (list) => Array.isArray(list) ? list.length : Object.keys(list).length;
|
|
482
|
+
/** Reads a file, or stdin for `-`. */
|
|
483
|
+
function read(path, stdin) {
|
|
484
|
+
if (path === "-") return stdin();
|
|
485
|
+
try {
|
|
486
|
+
return readFileSync(path, "utf8");
|
|
487
|
+
} catch (e) {
|
|
488
|
+
throw new UsageError(`cannot read ${path}: ${e.message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/** `@labels.json` and `--schema @invoice.json`. `@-` reads stdin. */
|
|
492
|
+
function loadJson(path, stdin) {
|
|
493
|
+
const raw = read(path, stdin);
|
|
494
|
+
try {
|
|
495
|
+
return JSON.parse(raw);
|
|
496
|
+
} catch (e) {
|
|
497
|
+
throw new UsageError(`${path === "-" ? "stdin" : path} is not JSON: ${e.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** `--schema` and `--tree` take a file, `@file`, `@-` or inline JSON. */
|
|
501
|
+
function loadDocument(flag, value, stdin) {
|
|
502
|
+
if (value.trimStart().startsWith("{")) try {
|
|
503
|
+
return JSON.parse(value);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
throw new UsageError(`--${flag} is not JSON: ${e.message}`);
|
|
506
|
+
}
|
|
507
|
+
return loadJson(value.replace(/^@/, ""), stdin);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Turns the positionals into the capability's list.
|
|
511
|
+
*
|
|
512
|
+
* `described` splits `name=description` on the first `=`. yes-no, answer and rate take free
|
|
513
|
+
* text, so they never split. `@path` loads a JSON array or a name-to-description object.
|
|
514
|
+
*/
|
|
515
|
+
function parseSpec(items, described, stdin) {
|
|
516
|
+
const names = [];
|
|
517
|
+
const map = {};
|
|
518
|
+
let mapped = false;
|
|
519
|
+
for (const item of items) {
|
|
520
|
+
if (item.startsWith("@")) {
|
|
521
|
+
const loaded = loadJson(item.slice(1), stdin);
|
|
522
|
+
if (Array.isArray(loaded)) names.push(...loaded.map(String));
|
|
523
|
+
else if (typeof loaded === "object" && loaded !== null) {
|
|
524
|
+
if (!described) throw new UsageError(`${item.slice(1)} holds a name-to-description object. yes-no, rate and answer take a JSON array.`);
|
|
525
|
+
mapped = true;
|
|
526
|
+
for (const [k, v] of Object.entries(loaded)) map[k] = String(v);
|
|
527
|
+
} else throw new UsageError(`${item.slice(1)} must hold an array or a name-to-description object.`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const at = described ? item.indexOf("=") : -1;
|
|
531
|
+
if (at > 0) {
|
|
532
|
+
mapped = true;
|
|
533
|
+
map[item.slice(0, at)] = item.slice(at + 1);
|
|
534
|
+
} else names.push(item);
|
|
535
|
+
}
|
|
536
|
+
if (!mapped) return {
|
|
537
|
+
list: names,
|
|
538
|
+
bare: true
|
|
539
|
+
};
|
|
540
|
+
for (const name of names) map[name] ??= "";
|
|
541
|
+
return {
|
|
542
|
+
list: map,
|
|
543
|
+
bare: false
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/cli/commands.ts
|
|
548
|
+
/** The flags, for node:util parseArgs. */
|
|
549
|
+
const OPTIONS = {
|
|
550
|
+
json: { type: "boolean" },
|
|
551
|
+
jsonl: { type: "boolean" },
|
|
552
|
+
raw: { type: "boolean" },
|
|
553
|
+
quiet: {
|
|
554
|
+
type: "boolean",
|
|
555
|
+
short: "q"
|
|
556
|
+
},
|
|
557
|
+
check: { type: "boolean" },
|
|
558
|
+
min: { type: "string" },
|
|
559
|
+
"min-confidence": { type: "string" },
|
|
560
|
+
key: { type: "string" },
|
|
561
|
+
"base-url": { type: "string" },
|
|
562
|
+
retries: { type: "string" },
|
|
563
|
+
timeout: { type: "string" },
|
|
564
|
+
usage: {
|
|
565
|
+
type: "boolean",
|
|
566
|
+
short: "v"
|
|
567
|
+
},
|
|
568
|
+
"no-color": { type: "boolean" },
|
|
569
|
+
help: {
|
|
570
|
+
type: "boolean",
|
|
571
|
+
short: "h"
|
|
572
|
+
},
|
|
573
|
+
version: {
|
|
574
|
+
type: "boolean",
|
|
575
|
+
short: "V"
|
|
576
|
+
},
|
|
577
|
+
file: {
|
|
578
|
+
type: "string",
|
|
579
|
+
short: "f",
|
|
580
|
+
multiple: true
|
|
581
|
+
},
|
|
582
|
+
lines: { type: "string" },
|
|
583
|
+
schema: { type: "string" },
|
|
584
|
+
tree: { type: "string" },
|
|
585
|
+
field: { type: "string" },
|
|
586
|
+
value: { type: "string" },
|
|
587
|
+
"when-true": { type: "string" },
|
|
588
|
+
"when-false": { type: "string" }
|
|
589
|
+
};
|
|
590
|
+
const need = (v, flag, example) => {
|
|
591
|
+
if (v === void 0 || v === "") throw new UsageError(`${flag} is required. For example: ${example}`);
|
|
592
|
+
return v;
|
|
593
|
+
};
|
|
594
|
+
const list = (l) => Array.isArray(l) ? l : Object.keys(l);
|
|
595
|
+
const asRow = (r) => r;
|
|
596
|
+
const asRows = (r) => r;
|
|
597
|
+
/** `field` takes a bare name or `name=description`. The SDK sends `{ name }` for a bare one. */
|
|
598
|
+
function fieldOf(raw) {
|
|
599
|
+
const at = raw.indexOf("=");
|
|
600
|
+
return at > 0 ? {
|
|
601
|
+
name: raw.slice(0, at),
|
|
602
|
+
description: raw.slice(at + 1)
|
|
603
|
+
} : raw;
|
|
604
|
+
}
|
|
605
|
+
const span = (r) => r.start === null ? "" : `${num(r, "start")}-${num(r, "end")}`;
|
|
606
|
+
const HELP = `dm1 — typed decisions over text, from milliseconds.ai
|
|
607
|
+
|
|
608
|
+
USAGE
|
|
609
|
+
dm1 <capability> [text] [args...] [options]
|
|
610
|
+
<command> | dm1 <capability> [args...]
|
|
611
|
+
|
|
612
|
+
CAPABILITIES
|
|
613
|
+
classify <text> <label[=description]>... Pick one label
|
|
614
|
+
yes-no <text> <statement>... True or false, per statement
|
|
615
|
+
rate <text> <level>... Place the text on a low-to-high scale
|
|
616
|
+
answer <text> <question>... Quote the answer out of the text
|
|
617
|
+
entities <text> <type[=description]>... Find every mention, with offsets
|
|
618
|
+
extract <text> --schema <file|json> Fill a JSON Schema
|
|
619
|
+
verify <text> --field <name> --value <v> Check a value against the text
|
|
620
|
+
classify-tree <text> --tree <file|json> Walk a nested label tree
|
|
621
|
+
check Test the key. Print the limits.
|
|
622
|
+
|
|
623
|
+
TEXT
|
|
624
|
+
[text] The text itself. Omit it, or pass -, to read stdin.
|
|
625
|
+
-f, --file <path> Read the text from a file. Repeat it for a batch, in order.
|
|
626
|
+
--lines <path> One text per line. Sent in chunks of 32, in order.
|
|
627
|
+
Piped input that starts with { becomes the whole request body. Flags still win.
|
|
628
|
+
|
|
629
|
+
SPECIFICATION
|
|
630
|
+
A positional after the text is a label, statement, level, question or type.
|
|
631
|
+
name=description splits on the first =. A bare name has no description.
|
|
632
|
+
Any list also loads from a file: @labels.json holds an array or a name-to-description object.
|
|
633
|
+
--tree @taxonomy.json --schema @invoice.json @- reads stdin.
|
|
634
|
+
--when-true <s> --when-false <s> yes-no hints
|
|
635
|
+
--field <name[=description]> --value <v> verify
|
|
636
|
+
|
|
637
|
+
OPTIONS
|
|
638
|
+
--json Print JSON, even on a terminal.
|
|
639
|
+
--jsonl One compact JSON result per line. For pipes.
|
|
640
|
+
--raw Print the result with its API envelope.
|
|
641
|
+
-q, --quiet Print the primary value only.
|
|
642
|
+
--check yes-no and verify only. Exit 3 when the answer is no.
|
|
643
|
+
--min <p> Exit 3 when probability is below <p>. Not rate or extract.
|
|
644
|
+
--min-confidence <c> Exit 3 when confidence is below <c>. classify, rate and
|
|
645
|
+
classify-tree only.
|
|
646
|
+
--key <key> API key. Default: $MS_API_KEY.
|
|
647
|
+
--base-url <url> Default: https://api.milliseconds.ai
|
|
648
|
+
--retries <n> Retries on 429, 502, 529 and network errors. Default 2.
|
|
649
|
+
--timeout <ms> Per attempt. Default 60000.
|
|
650
|
+
-v, --usage Print tokens, model time and rate limits to stderr.
|
|
651
|
+
--no-color No ANSI. NO_COLOR and a non-TTY stdout do the same.
|
|
652
|
+
-h, --help This text. After a capability, that capability's help.
|
|
653
|
+
-V, --version
|
|
654
|
+
|
|
655
|
+
DESCRIBE YOUR LABELS
|
|
656
|
+
The label text is the instruction. The model reads it literally.
|
|
657
|
+
dm1 classify "$T" billing shipping works
|
|
658
|
+
dm1 classify "$T" billing="charges and refunds" shipping="delivery and tracking"
|
|
659
|
+
The second call scores measurably better. Use name=description everywhere.
|
|
660
|
+
|
|
661
|
+
EXIT CODES
|
|
662
|
+
0 decided 2 bad usage
|
|
663
|
+
1 API error 3 --check or --min failed
|
|
664
|
+
|
|
665
|
+
EXAMPLES
|
|
666
|
+
dm1 classify "I was charged twice" billing shipping account
|
|
667
|
+
dm1 yes-no "Ship it today" "The customer expresses urgency." --check
|
|
668
|
+
dm1 rate "This is unacceptable" Calm Annoyed Angry "Threatening to leave"
|
|
669
|
+
dm1 entities "Ada met Grace in Paris" person place --json | jq -r '.[].text'
|
|
670
|
+
dm1 extract -f invoice.txt --schema @invoice.json
|
|
671
|
+
dm1 classify --lines tickets.txt billing shipping account --jsonl > labelled.ndjson
|
|
672
|
+
pbpaste | dm1 classify @labels.json
|
|
673
|
+
|
|
674
|
+
No key yet? https://console.milliseconds.ai then export MS_API_KEY=sk-ms-...
|
|
675
|
+
`;
|
|
676
|
+
const CHECK_HELP = `
|
|
677
|
+
dm1 check [options]
|
|
678
|
+
|
|
679
|
+
Tests the key and prints the limits. It sends one small classify, about 20 tokens.
|
|
680
|
+
Do not run it in a health-check loop.
|
|
681
|
+
|
|
682
|
+
EXAMPLES
|
|
683
|
+
dm1 check
|
|
684
|
+
dm1 check --base-url https://api.milliseconds.ai --key sk-ms-...
|
|
685
|
+
`;
|
|
686
|
+
const CAPABILITIES = {
|
|
687
|
+
classify: {
|
|
688
|
+
name: "classify",
|
|
689
|
+
described: true,
|
|
690
|
+
envelope: null,
|
|
691
|
+
checkable: false,
|
|
692
|
+
probability: true,
|
|
693
|
+
confidence: true,
|
|
694
|
+
hint: "dm1 classify \"I was charged twice.\" billing=\"charges and refunds\" shipping=\"delivery\"",
|
|
695
|
+
help: `
|
|
696
|
+
dm1 classify [text] <label[=description]>... [options]
|
|
697
|
+
|
|
698
|
+
Picks one label and returns the full distribution.
|
|
699
|
+
POST /v1/decision-machine-1/classify
|
|
700
|
+
|
|
701
|
+
ARGUMENTS
|
|
702
|
+
[text] The text. Omit it, or pass -, to read stdin.
|
|
703
|
+
<label[=description]> 2 to 64 labels. Describe each one for better accuracy.
|
|
704
|
+
@labels.json loads an array or a name-to-description object.
|
|
705
|
+
|
|
706
|
+
RESULT
|
|
707
|
+
label, probability, confidence, scores
|
|
708
|
+
|
|
709
|
+
PREDICATES
|
|
710
|
+
--min <p> Exit 3 when probability is below p.
|
|
711
|
+
--min-confidence <c> Exit 3 when confidence is below c.
|
|
712
|
+
|
|
713
|
+
EXAMPLES
|
|
714
|
+
dm1 classify "I was charged twice." billing="charges and refunds" shipping="delivery"
|
|
715
|
+
dm1 classify -f ticket.txt @labels.json --json
|
|
716
|
+
dm1 classify --lines tickets.txt @labels.json --jsonl | jq -r '.label'
|
|
717
|
+
cat ticket.txt | dm1 classify @labels.json --min 0.9 -q
|
|
718
|
+
`,
|
|
719
|
+
call: (c) => c.dm.classify(c.input, c.list),
|
|
720
|
+
fragment: (c) => specSize(c.list) > 0 ? { labels: c.list } : {},
|
|
721
|
+
table: (r, dim) => {
|
|
722
|
+
const o = asRow(r);
|
|
723
|
+
return `${kv([
|
|
724
|
+
["label", text(o, "label")],
|
|
725
|
+
["probability", p3(num(o, "probability"))],
|
|
726
|
+
["confidence", p3(num(o, "confidence"))]
|
|
727
|
+
], dim)}\n\n${scores(Object.entries(o.scores), dim, true)}`;
|
|
728
|
+
},
|
|
729
|
+
quiet: (r) => [text(asRow(r), "label")]
|
|
730
|
+
},
|
|
731
|
+
"yes-no": {
|
|
732
|
+
name: "yes-no",
|
|
733
|
+
described: false,
|
|
734
|
+
envelope: "results",
|
|
735
|
+
checkable: true,
|
|
736
|
+
probability: true,
|
|
737
|
+
confidence: false,
|
|
738
|
+
hint: "dm1 yes-no \"Fix this today.\" \"The customer expresses urgency.\"",
|
|
739
|
+
help: `
|
|
740
|
+
dm1 yes-no [text] <statement>... [options]
|
|
741
|
+
|
|
742
|
+
Answers each statement with yes or no and a probability.
|
|
743
|
+
POST /v1/decision-machine-1/yes-no
|
|
744
|
+
|
|
745
|
+
ARGUMENTS
|
|
746
|
+
[text] The text. Omit it, or pass -, to read stdin.
|
|
747
|
+
<statement> 1 to 32 third-person claims about the text. They share one
|
|
748
|
+
inference call, so extra statements are nearly free.
|
|
749
|
+
|
|
750
|
+
FLAGS
|
|
751
|
+
--when-true <s> What makes the statement true. It improves accuracy.
|
|
752
|
+
--when-false <s> What makes it false.
|
|
753
|
+
--check Exit 3 when the answer is no. In a batch, any no fails the run.
|
|
754
|
+
|
|
755
|
+
RESULT
|
|
756
|
+
statement, answer, probability
|
|
757
|
+
|
|
758
|
+
EXAMPLES
|
|
759
|
+
dm1 yes-no "Fix this today." "The customer expresses urgency."
|
|
760
|
+
dm1 yes-no -f reply.txt "The reply promises a refund." --check --min 0.9 -q
|
|
761
|
+
dm1 yes-no -f a.txt -f b.txt "The text mentions a price." --jsonl
|
|
762
|
+
`,
|
|
763
|
+
call: (c) => c.dm.yesNo(c.input, list(c.list), {
|
|
764
|
+
...c.v["when-true"] === void 0 ? {} : { when_true: c.v["when-true"] },
|
|
765
|
+
...c.v["when-false"] === void 0 ? {} : { when_false: c.v["when-false"] }
|
|
766
|
+
}),
|
|
767
|
+
fragment: (c) => ({
|
|
768
|
+
...specSize(c.list) > 0 ? {
|
|
769
|
+
statements: list(c.list),
|
|
770
|
+
statement: void 0
|
|
771
|
+
} : {},
|
|
772
|
+
...c.v["when-true"] === void 0 ? {} : { when_true: c.v["when-true"] },
|
|
773
|
+
...c.v["when-false"] === void 0 ? {} : { when_false: c.v["when-false"] }
|
|
774
|
+
}),
|
|
775
|
+
table: (r) => columns(asRows(r).map((o) => [
|
|
776
|
+
o.answer === true ? "yes" : "no",
|
|
777
|
+
p3(num(o, "probability")),
|
|
778
|
+
text(o, "statement")
|
|
779
|
+
])),
|
|
780
|
+
quiet: (r) => asRows(r).map((o) => o.answer === true ? "yes" : "no")
|
|
781
|
+
},
|
|
782
|
+
rate: {
|
|
783
|
+
name: "rate",
|
|
784
|
+
described: false,
|
|
785
|
+
envelope: null,
|
|
786
|
+
checkable: false,
|
|
787
|
+
probability: false,
|
|
788
|
+
confidence: true,
|
|
789
|
+
hint: "dm1 rate \"I am done with this company.\" Calm Annoyed Angry \"Threatening to leave\"",
|
|
790
|
+
help: `
|
|
791
|
+
dm1 rate [text] <level>... [options]
|
|
792
|
+
|
|
793
|
+
Places the text on an ordered scale of described levels.
|
|
794
|
+
POST /v1/decision-machine-1/rate
|
|
795
|
+
|
|
796
|
+
ARGUMENTS
|
|
797
|
+
[text] The text. Omit it, or pass -, to read stdin.
|
|
798
|
+
<level> 2 to 10 level descriptions, low to high, in order. Never numbers.
|
|
799
|
+
|
|
800
|
+
RESULT
|
|
801
|
+
score, level, label, confidence, scores
|
|
802
|
+
Route on score and confidence. level flips on 0.001.
|
|
803
|
+
|
|
804
|
+
EXAMPLES
|
|
805
|
+
dm1 rate "I am done with this company." Calm Annoyed Angry "Threatening to leave"
|
|
806
|
+
dm1 rate -f email.txt @scale.json --usage
|
|
807
|
+
`,
|
|
808
|
+
call: (c) => c.dm.rate(c.input, list(c.list)),
|
|
809
|
+
fragment: (c) => specSize(c.list) > 0 ? { scale: list(c.list) } : {},
|
|
810
|
+
table: (r, dim, names) => {
|
|
811
|
+
const o = asRow(r);
|
|
812
|
+
const scale = o.scores.map((p, i) => [names[i] ?? `${i}`, p]);
|
|
813
|
+
return `${kv([
|
|
814
|
+
["label", text(o, "label")],
|
|
815
|
+
["score", num(o, "score").toFixed(2)],
|
|
816
|
+
["level", text(o, "level")],
|
|
817
|
+
["confidence", p3(num(o, "confidence"))]
|
|
818
|
+
], dim)}\n\n${scores(scale, dim, false)}`;
|
|
819
|
+
},
|
|
820
|
+
quiet: (r) => [text(asRow(r), "label")]
|
|
821
|
+
},
|
|
822
|
+
answer: {
|
|
823
|
+
name: "answer",
|
|
824
|
+
described: false,
|
|
825
|
+
envelope: "results",
|
|
826
|
+
checkable: false,
|
|
827
|
+
probability: true,
|
|
828
|
+
confidence: false,
|
|
829
|
+
hint: "dm1 answer -f press.txt \"Who announced the product?\" \"How much does it cost?\"",
|
|
830
|
+
help: `
|
|
831
|
+
dm1 answer [text] <question>... [options]
|
|
832
|
+
|
|
833
|
+
Quotes the answer out of the text, with its offsets.
|
|
834
|
+
POST /v1/decision-machine-1/answer
|
|
835
|
+
|
|
836
|
+
ARGUMENTS
|
|
837
|
+
[text] The text. Omit it, or pass -, to read stdin.
|
|
838
|
+
<question> 1 to 32 questions. They share one inference call.
|
|
839
|
+
Name the role, not the type: "the date the payment is due".
|
|
840
|
+
|
|
841
|
+
RESULT
|
|
842
|
+
question, answer, probability, start, end
|
|
843
|
+
answer is null when nothing fits. start and end are then null too.
|
|
844
|
+
Offsets index the text at the same position in the batch, never a joined string.
|
|
845
|
+
|
|
846
|
+
EXAMPLES
|
|
847
|
+
dm1 answer -f press.txt "Who announced the product?" "How much does it cost?"
|
|
848
|
+
curl -s https://example.com/press.txt | dm1 answer - "Who announced the product?"
|
|
849
|
+
`,
|
|
850
|
+
call: (c) => c.dm.answer(c.input, list(c.list)),
|
|
851
|
+
fragment: (c) => specSize(c.list) > 0 ? {
|
|
852
|
+
questions: list(c.list),
|
|
853
|
+
question: void 0
|
|
854
|
+
} : {},
|
|
855
|
+
table: (r) => columns(asRows(r).map((o) => [
|
|
856
|
+
o.answer === null ? "-" : text(o, "answer"),
|
|
857
|
+
p3(num(o, "probability")),
|
|
858
|
+
span(o),
|
|
859
|
+
text(o, "question")
|
|
860
|
+
])),
|
|
861
|
+
quiet: (r) => asRows(r).map((o) => o.answer === null ? "" : text(o, "answer"))
|
|
862
|
+
},
|
|
863
|
+
entities: {
|
|
864
|
+
name: "entities",
|
|
865
|
+
described: true,
|
|
866
|
+
envelope: "entities",
|
|
867
|
+
checkable: false,
|
|
868
|
+
probability: true,
|
|
869
|
+
confidence: false,
|
|
870
|
+
hint: "dm1 entities \"Ada met Grace in Paris.\" person=\"a human name\" place=\"a city or country\"",
|
|
871
|
+
help: `
|
|
872
|
+
dm1 entities [text] <type[=description]>... [options]
|
|
873
|
+
|
|
874
|
+
Finds every span matching each type, with offsets.
|
|
875
|
+
POST /v1/decision-machine-1/entities
|
|
876
|
+
|
|
877
|
+
ARGUMENTS
|
|
878
|
+
[text] The text. Omit it, or pass -, to read stdin.
|
|
879
|
+
<type[=description]> 1 to 64 entity types. Describe each one.
|
|
880
|
+
|
|
881
|
+
RESULT
|
|
882
|
+
A list of { type, text, probability, start, end }, sorted by start.
|
|
883
|
+
|
|
884
|
+
EXAMPLES
|
|
885
|
+
dm1 entities "Ada met Grace in Paris." person="a human name" place="a city or country"
|
|
886
|
+
pbpaste | dm1 entities @types.json --json | jq -r '.[] | select(.type=="person") | .text'
|
|
887
|
+
`,
|
|
888
|
+
call: (c) => c.dm.entities(c.input, c.list),
|
|
889
|
+
fragment: (c) => specSize(c.list) > 0 ? { types: c.list } : {},
|
|
890
|
+
table: (r) => columns(asRows(r).map((o) => [
|
|
891
|
+
text(o, "type"),
|
|
892
|
+
text(o, "text"),
|
|
893
|
+
p3(num(o, "probability")),
|
|
894
|
+
span(o)
|
|
895
|
+
])),
|
|
896
|
+
quiet: (r) => asRows(r).map((o) => text(o, "text"))
|
|
897
|
+
},
|
|
898
|
+
extract: {
|
|
899
|
+
name: "extract",
|
|
900
|
+
described: true,
|
|
901
|
+
envelope: "data",
|
|
902
|
+
checkable: false,
|
|
903
|
+
probability: false,
|
|
904
|
+
confidence: false,
|
|
905
|
+
hint: "dm1 extract -f invoice.txt --schema @invoice.json --json > invoice.json",
|
|
906
|
+
help: `
|
|
907
|
+
dm1 extract [text] --schema <file|json> [options]
|
|
908
|
+
|
|
909
|
+
Fills a JSON Schema from the text.
|
|
910
|
+
POST /v1/decision-machine-1/extract
|
|
911
|
+
|
|
912
|
+
FLAGS
|
|
913
|
+
--schema @invoice.json The JSON Schema. @- reads stdin. Inline JSON works too.
|
|
914
|
+
|
|
915
|
+
RESULT
|
|
916
|
+
The data object. Missing values are null. Arrays of objects come back empty.
|
|
917
|
+
Arrays of scalars come back as strings. Enums are not checked server side.
|
|
918
|
+
|
|
919
|
+
EXAMPLES
|
|
920
|
+
dm1 extract -f invoice.txt --schema @invoice.json --json > invoice.json
|
|
921
|
+
dm1 extract -f a.txt -f b.txt --schema @invoice.json --jsonl
|
|
922
|
+
`,
|
|
923
|
+
call: (c) => c.dm.extract(c.input, loadDocument("schema", need(c.v.schema, "--schema", "dm1 extract -f invoice.txt --schema @invoice.json"), c.stdin)),
|
|
924
|
+
fragment: (c) => c.v.schema === void 0 ? {} : { schema: loadDocument("schema", c.v.schema, c.stdin) },
|
|
925
|
+
table: (r) => columns(Object.entries(asRow(r)).map(([k, v]) => [k, v === null ? "-" : typeof v === "object" ? JSON.stringify(v) : String(v)])),
|
|
926
|
+
quiet: (r) => [JSON.stringify(r)]
|
|
927
|
+
},
|
|
928
|
+
verify: {
|
|
929
|
+
name: "verify",
|
|
930
|
+
described: false,
|
|
931
|
+
envelope: null,
|
|
932
|
+
checkable: true,
|
|
933
|
+
probability: true,
|
|
934
|
+
confidence: false,
|
|
935
|
+
hint: "dm1 verify -f invoice.txt --field total --value 999 --json | jq .found",
|
|
936
|
+
help: `
|
|
937
|
+
dm1 verify [text] --field <name[=description]> --value <v> [options]
|
|
938
|
+
|
|
939
|
+
Checks whether the text says <v> for <field>.
|
|
940
|
+
POST /v1/decision-machine-1/verify
|
|
941
|
+
|
|
942
|
+
FLAGS
|
|
943
|
+
--field <name[=description]> The field to read out of the text.
|
|
944
|
+
--value <v> The value you already hold.
|
|
945
|
+
--check Exit 3 when the value does not match.
|
|
946
|
+
|
|
947
|
+
RESULT
|
|
948
|
+
matches, probability, found
|
|
949
|
+
found shows what the text actually says.
|
|
950
|
+
|
|
951
|
+
EXAMPLES
|
|
952
|
+
dm1 verify -f invoice.txt --field invoice_number="the identifier printed on the invoice" \\
|
|
953
|
+
--value 4471 --check
|
|
954
|
+
dm1 verify -f invoice.txt --field total --value 999 --json | jq .found
|
|
955
|
+
`,
|
|
956
|
+
call: (c) => c.dm.verify(c.input, fieldOf(need(c.v.field, "--field", "dm1 verify -f invoice.txt --field total --value 999")), need(c.v.value, "--value", "dm1 verify -f invoice.txt --field total --value 999")),
|
|
957
|
+
fragment: (c) => ({
|
|
958
|
+
...c.v.field === void 0 ? {} : { field: fieldOf(c.v.field) },
|
|
959
|
+
...c.v.value === void 0 ? {} : { value: c.v.value }
|
|
960
|
+
}),
|
|
961
|
+
table: (r, dim) => {
|
|
962
|
+
const o = asRow(r);
|
|
963
|
+
return kv([
|
|
964
|
+
["matches", o.matches === true ? "yes" : "no"],
|
|
965
|
+
["probability", p3(num(o, "probability"))],
|
|
966
|
+
["found", o.found.join(", ")]
|
|
967
|
+
], dim);
|
|
968
|
+
},
|
|
969
|
+
quiet: (r) => [asRow(r).matches === true ? "yes" : "no"]
|
|
970
|
+
},
|
|
971
|
+
"classify-tree": {
|
|
972
|
+
name: "classify-tree",
|
|
973
|
+
described: false,
|
|
974
|
+
envelope: null,
|
|
975
|
+
checkable: false,
|
|
976
|
+
probability: true,
|
|
977
|
+
confidence: true,
|
|
978
|
+
hint: "dm1 classify-tree -f ticket.txt --tree @taxonomy.json --json",
|
|
979
|
+
help: `
|
|
980
|
+
dm1 classify-tree [text] --tree <file|json> [options]
|
|
981
|
+
|
|
982
|
+
Runs classify once per level of a nested tree, descending into the winner.
|
|
983
|
+
POST /v1/decision-machine-1/classify-tree
|
|
984
|
+
|
|
985
|
+
FLAGS
|
|
986
|
+
--tree @taxonomy.json name -> description, or name -> { description, labels }.
|
|
987
|
+
2 to 64 labels per level, at most 8 levels.
|
|
988
|
+
|
|
989
|
+
RESULT
|
|
990
|
+
path, label, probability, confidence, levels[]
|
|
991
|
+
probability and confidence are products over the levels, so they fall with depth.
|
|
992
|
+
Each level re-sends the text, so the per-level input numbers do not sum to the
|
|
993
|
+
x-input-chars header.
|
|
994
|
+
|
|
995
|
+
EXAMPLES
|
|
996
|
+
dm1 classify-tree -f ticket.txt --tree @taxonomy.json --json
|
|
997
|
+
dm1 classify-tree "I want my money back." --tree @taxonomy.json -q
|
|
998
|
+
`,
|
|
999
|
+
call: (c) => c.dm.classifyTree(c.input, loadDocument("tree", need(c.v.tree, "--tree", "dm1 classify-tree -f ticket.txt --tree @taxonomy.json"), c.stdin)),
|
|
1000
|
+
fragment: (c) => c.v.tree === void 0 ? {} : { tree: loadDocument("tree", c.v.tree, c.stdin) },
|
|
1001
|
+
table: (r, dim) => {
|
|
1002
|
+
const o = asRow(r);
|
|
1003
|
+
const levels = o.levels;
|
|
1004
|
+
const head = kv([
|
|
1005
|
+
["label", text(o, "label")],
|
|
1006
|
+
["path", o.path.join(" > ")],
|
|
1007
|
+
["probability", p3(num(o, "probability"))],
|
|
1008
|
+
["confidence", p3(num(o, "confidence"))]
|
|
1009
|
+
], dim);
|
|
1010
|
+
const body = columns(levels.map((l, i) => [
|
|
1011
|
+
` ${i + 1}`,
|
|
1012
|
+
text(l, "label"),
|
|
1013
|
+
p3(num(l, "probability")),
|
|
1014
|
+
p3(num(l, "confidence")),
|
|
1015
|
+
`${int(num(l, "inference_ms"))} ms`
|
|
1016
|
+
]));
|
|
1017
|
+
return `${head}\n\n${dim("levels")}\n${body}`;
|
|
1018
|
+
},
|
|
1019
|
+
quiet: (r) => [text(asRow(r), "label")]
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/cli/input.ts
|
|
1024
|
+
/** One trailing newline is a file ending, not text. It would bill and print. */
|
|
1025
|
+
function readFile(path) {
|
|
1026
|
+
try {
|
|
1027
|
+
return readFileSync(path, "utf8").replace(/\r?\n$/, "");
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
throw new UsageError(`cannot read ${path}: ${e.message}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const NO_TEXT = "no text. Pass the text, or -f <file>, or --lines <file>, or pipe the text in.";
|
|
1033
|
+
/** A @file and a name=description are list arguments. Anything else could be the text. */
|
|
1034
|
+
const listOnly = (item) => item.startsWith("@") || item.includes("=");
|
|
1035
|
+
/** The pipe wins over a positional, so say which argument lost the text. */
|
|
1036
|
+
const shadowed = (first) => `dm1: the text comes from stdin. "${first.length > 32 ? `${first.slice(0, 31)}…` : first}" is a list argument, not the text. Pass - to silence this.`;
|
|
1037
|
+
/**
|
|
1038
|
+
* One source only.
|
|
1039
|
+
*
|
|
1040
|
+
* `--file` and `--lines` win. A leading `-` reads stdin. A pipe that carries text reads
|
|
1041
|
+
* stdin, and every positional then belongs to the capability's list. Otherwise the first
|
|
1042
|
+
* positional is the text. No text anywhere is a usage error.
|
|
1043
|
+
*/
|
|
1044
|
+
function resolveInput(s) {
|
|
1045
|
+
if (s.lines !== void 0) {
|
|
1046
|
+
const texts = readFile(s.lines).split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
1047
|
+
if (texts.length === 0) throw new UsageError(`${s.lines} holds no text.`);
|
|
1048
|
+
return {
|
|
1049
|
+
input: texts,
|
|
1050
|
+
items: s.positionals
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
if (s.files.length > 0) {
|
|
1054
|
+
const texts = s.files.map(readFile);
|
|
1055
|
+
return {
|
|
1056
|
+
input: texts.length === 1 ? texts[0] : texts,
|
|
1057
|
+
items: s.positionals
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
let items = s.positionals;
|
|
1061
|
+
let explicit = false;
|
|
1062
|
+
if (items[0] === "-") {
|
|
1063
|
+
explicit = true;
|
|
1064
|
+
items = items.slice(1);
|
|
1065
|
+
}
|
|
1066
|
+
const raw = explicit || !s.stdinIsTty ? s.stdin() : "";
|
|
1067
|
+
const trimmed = raw.trim();
|
|
1068
|
+
if (trimmed === "") {
|
|
1069
|
+
if (explicit) throw new UsageError("stdin is empty. Send the text in, or pass -f <file>.");
|
|
1070
|
+
if (items.length === 0) throw new UsageError(NO_TEXT);
|
|
1071
|
+
return {
|
|
1072
|
+
input: items[0],
|
|
1073
|
+
items: items.slice(1)
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
if (!trimmed.startsWith("{")) {
|
|
1077
|
+
const first = items[0];
|
|
1078
|
+
const quiet = explicit || first === void 0 || listOnly(first);
|
|
1079
|
+
return {
|
|
1080
|
+
input: raw.replace(/\r?\n$/, ""),
|
|
1081
|
+
items,
|
|
1082
|
+
...quiet ? {} : { note: shadowed(first) }
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
let body;
|
|
1086
|
+
try {
|
|
1087
|
+
body = JSON.parse(trimmed);
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
throw new UsageError(`stdin starts with { but is not JSON: ${e.message}. Fix the JSON, or send plain text with -f <file>.`);
|
|
1090
|
+
}
|
|
1091
|
+
if (Array.isArray(body.texts)) {
|
|
1092
|
+
if (body.texts.length === 0) throw new UsageError("the piped body has an empty texts array. Send at least one text.");
|
|
1093
|
+
return {
|
|
1094
|
+
input: body.texts,
|
|
1095
|
+
items,
|
|
1096
|
+
body
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
if (typeof body.text !== "string") throw new UsageError("the piped body has no text and no texts. Add one of them to the JSON.");
|
|
1100
|
+
return {
|
|
1101
|
+
input: body.text,
|
|
1102
|
+
items,
|
|
1103
|
+
body
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
function chunk(texts) {
|
|
1107
|
+
const out = [];
|
|
1108
|
+
for (let i = 0; i < texts.length; i += 32) out.push(texts.slice(i, i + 32));
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/cli/run.ts
|
|
1113
|
+
const VERSION = "0.1.0";
|
|
1114
|
+
const path = (name) => `/v1/decision-machine-1/${name}`;
|
|
1115
|
+
const wrapOne = (v, env) => env === null ? v : env === "results" ? { results: v } : env === "entities" ? { entities: v } : { data: v };
|
|
1116
|
+
const unwrapOne = (raw, env) => env === null ? raw : raw[env];
|
|
1117
|
+
const number = (flag, raw) => {
|
|
1118
|
+
const n = Number(raw);
|
|
1119
|
+
if (!Number.isFinite(n)) throw new UsageError(`${flag} takes a number, not ${raw}.`);
|
|
1120
|
+
return n;
|
|
1121
|
+
};
|
|
1122
|
+
/** Every result of one text: the object itself, or each element of a list. */
|
|
1123
|
+
const items = (result) => Array.isArray(result) ? result : [result];
|
|
1124
|
+
/** `dm1: <code>: <message>` on stderr, then the capability's first example. */
|
|
1125
|
+
function fail(io, e, cap) {
|
|
1126
|
+
const hint = () => {
|
|
1127
|
+
if (cap) io.err(`\n ${cap.hint}`);
|
|
1128
|
+
};
|
|
1129
|
+
if (e instanceof UsageError) {
|
|
1130
|
+
io.err(`dm1: usage: ${e.message}`);
|
|
1131
|
+
hint();
|
|
1132
|
+
return 2;
|
|
1133
|
+
}
|
|
1134
|
+
if (isMillisecondsError(e)) {
|
|
1135
|
+
io.err(`dm1: ${e.code}: ${e.message}`);
|
|
1136
|
+
const local = e.status === 0 && (e.code === "client_error" || e.code === "invalid_schema");
|
|
1137
|
+
if (local) hint();
|
|
1138
|
+
return local ? 2 : 1;
|
|
1139
|
+
}
|
|
1140
|
+
io.err(`dm1: internal_error: ${e.message}`);
|
|
1141
|
+
return 1;
|
|
1142
|
+
}
|
|
1143
|
+
async function run(io) {
|
|
1144
|
+
const found = {};
|
|
1145
|
+
try {
|
|
1146
|
+
return await dispatch(io, found);
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
return fail(io, e, found.cap);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
async function dispatch(io, found) {
|
|
1152
|
+
let parsed;
|
|
1153
|
+
try {
|
|
1154
|
+
parsed = parseArgs({
|
|
1155
|
+
args: io.argv,
|
|
1156
|
+
options: OPTIONS,
|
|
1157
|
+
allowPositionals: true
|
|
1158
|
+
});
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
throw new UsageError(`${e.message}\n Run dm1 --help.`);
|
|
1161
|
+
}
|
|
1162
|
+
const v = parsed.values;
|
|
1163
|
+
const [name, ...rest] = parsed.positionals;
|
|
1164
|
+
if (v.version) {
|
|
1165
|
+
io.out(VERSION);
|
|
1166
|
+
return 0;
|
|
1167
|
+
}
|
|
1168
|
+
if (name === void 0) {
|
|
1169
|
+
io.out(HELP);
|
|
1170
|
+
return 0;
|
|
1171
|
+
}
|
|
1172
|
+
const color = io.stdoutIsTty && v["no-color"] !== true && !io.env.NO_COLOR;
|
|
1173
|
+
if (name === "check") {
|
|
1174
|
+
if (v.help === true) {
|
|
1175
|
+
io.out(CHECK_HELP);
|
|
1176
|
+
return 0;
|
|
1177
|
+
}
|
|
1178
|
+
return await check(io, client(io, v), v, color);
|
|
1179
|
+
}
|
|
1180
|
+
const cap = CAPABILITIES[name];
|
|
1181
|
+
if (!cap) throw new UsageError(`unknown capability ${name}. Run dm1 --help.`);
|
|
1182
|
+
found.cap = cap;
|
|
1183
|
+
if (v.help === true) {
|
|
1184
|
+
io.out(cap.help);
|
|
1185
|
+
return 0;
|
|
1186
|
+
}
|
|
1187
|
+
if (v.check === true && !cap.checkable) throw new UsageError(`--check works with yes-no and verify only, not ${name}.`);
|
|
1188
|
+
const min = v.min === void 0 ? null : number("--min", v.min);
|
|
1189
|
+
const minConfidence = v["min-confidence"] === void 0 ? null : number("--min-confidence", v["min-confidence"]);
|
|
1190
|
+
if (min !== null && !cap.probability) throw new UsageError(`--min reads a probability. A ${name} result carries none.`);
|
|
1191
|
+
if (minConfidence !== null && !cap.confidence) throw new UsageError(`--min-confidence reads a confidence. A ${name} result carries none.`);
|
|
1192
|
+
const piped = io.argv.includes("-") || io.argv.includes("@-") || !io.stdinIsTty && v.lines === void 0 && (v.file ?? []).length === 0 ? await io.stdin() : "";
|
|
1193
|
+
const stdin = () => piped;
|
|
1194
|
+
const source = resolveInput({
|
|
1195
|
+
files: v.file ?? [],
|
|
1196
|
+
lines: v.lines,
|
|
1197
|
+
positionals: rest,
|
|
1198
|
+
stdin,
|
|
1199
|
+
stdinIsTty: io.stdinIsTty
|
|
1200
|
+
});
|
|
1201
|
+
if (source.note !== void 0) io.err(source.note);
|
|
1202
|
+
const spec = parseSpec(source.items, cap.described, stdin);
|
|
1203
|
+
const names = Array.isArray(spec.list) ? spec.list : Object.keys(spec.list);
|
|
1204
|
+
const bare = spec.bare ? names[0] : void 0;
|
|
1205
|
+
const dm = client(io, v);
|
|
1206
|
+
const mode = v.raw === true ? "raw" : v.jsonl === true ? "jsonl" : v.quiet === true ? "quiet" : v.json === true || !io.stdoutIsTty ? "json" : "table";
|
|
1207
|
+
const body = source.body ? {
|
|
1208
|
+
...source.body,
|
|
1209
|
+
...cap.fragment({
|
|
1210
|
+
dm,
|
|
1211
|
+
input: source.input,
|
|
1212
|
+
list: spec.list,
|
|
1213
|
+
v,
|
|
1214
|
+
stdin
|
|
1215
|
+
})
|
|
1216
|
+
} : null;
|
|
1217
|
+
const batch = Array.isArray(source.input);
|
|
1218
|
+
const env = body ? envelopeOf(cap, body) : cap.envelope;
|
|
1219
|
+
const pieces = batch ? chunk(source.input) : [source.input];
|
|
1220
|
+
const decided = [];
|
|
1221
|
+
const total = {
|
|
1222
|
+
chars: 0,
|
|
1223
|
+
tokens: 0,
|
|
1224
|
+
ms: 0,
|
|
1225
|
+
rateLimit: null
|
|
1226
|
+
};
|
|
1227
|
+
const streaming = mode === "jsonl" || mode === "quiet";
|
|
1228
|
+
for (const [i, piece] of pieces.entries()) {
|
|
1229
|
+
let value;
|
|
1230
|
+
let usage;
|
|
1231
|
+
try {
|
|
1232
|
+
const got = await (body ? dm.post(path(cap.name), body) : cap.call({
|
|
1233
|
+
dm,
|
|
1234
|
+
input: piece,
|
|
1235
|
+
list: spec.list,
|
|
1236
|
+
v,
|
|
1237
|
+
stdin
|
|
1238
|
+
})).withUsage();
|
|
1239
|
+
usage = got.usage;
|
|
1240
|
+
value = body ? unwrapBody(got.result, env, batch) : got.result;
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
if (pieces.length > 1 && isMillisecondsError(e) && e.status !== 0) {
|
|
1243
|
+
if (!streaming) emit(io, decided, mode, cap, color, batch, bare, names);
|
|
1244
|
+
const first = i * 32 + 1;
|
|
1245
|
+
io.err(`dm1: ${e.code}: chunk ${i + 1} of ${pieces.length} (texts ${first}-${first + piece.length - 1}) failed.\n${e.message}`);
|
|
1246
|
+
if (v.usage === true) io.err(usageLine(total));
|
|
1247
|
+
return 1;
|
|
1248
|
+
}
|
|
1249
|
+
if (v.usage === true && total.chars > 0) io.err(usageLine(total));
|
|
1250
|
+
throw e;
|
|
1251
|
+
}
|
|
1252
|
+
total.chars += usage.inputChars;
|
|
1253
|
+
total.tokens += usage.inputTokens;
|
|
1254
|
+
total.ms += usage.inferenceMs;
|
|
1255
|
+
total.rateLimit = usage.rateLimit ?? total.rateLimit;
|
|
1256
|
+
const part = Array.isArray(piece) ? piece.map((t, k) => ({
|
|
1257
|
+
text: t,
|
|
1258
|
+
result: value[k],
|
|
1259
|
+
raw: wrapOne(value[k], env)
|
|
1260
|
+
})) : [{
|
|
1261
|
+
text: piece,
|
|
1262
|
+
result: value,
|
|
1263
|
+
raw: wrapOne(value, env)
|
|
1264
|
+
}];
|
|
1265
|
+
if (streaming) emit(io, part, mode, cap, color, batch, bare, names);
|
|
1266
|
+
decided.push(...part);
|
|
1267
|
+
}
|
|
1268
|
+
if (!streaming) emit(io, decided, mode, cap, color, batch, bare, names);
|
|
1269
|
+
if (v.usage === true) io.err(usageLine(total));
|
|
1270
|
+
return failed(decided, v, min, minConfidence, cap) ? 3 : 0;
|
|
1271
|
+
}
|
|
1272
|
+
/** The CLI never chunks a piped body: the user wrote it, so the SDK sends it unchanged. */
|
|
1273
|
+
function unwrapBody(raw, env, batch) {
|
|
1274
|
+
if (!batch) return unwrapOne(raw, env);
|
|
1275
|
+
return (raw.results ?? []).map((r) => unwrapOne(r, env));
|
|
1276
|
+
}
|
|
1277
|
+
/** A piped body picks its own shape: `statement` answers one result, `statements` a list. */
|
|
1278
|
+
function envelopeOf(cap, body) {
|
|
1279
|
+
if (cap.name === "yes-no") return body.statement === void 0 ? "results" : null;
|
|
1280
|
+
if (cap.name === "answer") return body.question === void 0 ? "results" : null;
|
|
1281
|
+
return cap.envelope;
|
|
1282
|
+
}
|
|
1283
|
+
function client(io, v) {
|
|
1284
|
+
if (!(v.key ?? io.env.MS_API_KEY)) throw new UsageError("no API key. Run export MS_API_KEY=sk-ms-..., or pass --key. Get a key at https://console.milliseconds.ai.");
|
|
1285
|
+
return new DecisionMachine({
|
|
1286
|
+
apiKey: v.key ?? io.env.MS_API_KEY,
|
|
1287
|
+
...v["base-url"] === void 0 ? {} : { baseUrl: v["base-url"] },
|
|
1288
|
+
...v.retries === void 0 ? {} : { maxRetries: number("--retries", v.retries) },
|
|
1289
|
+
...v.timeout === void 0 ? {} : { timeout: number("--timeout", v.timeout) },
|
|
1290
|
+
...io.fetch ? { fetch: io.fetch } : {}
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
/** stdout holds results only. One blank line separates the texts of a batch. */
|
|
1294
|
+
function emit(io, decided, mode, cap, color, batch, bare, names) {
|
|
1295
|
+
if (decided.length === 0) return;
|
|
1296
|
+
const dim = dimmer(color);
|
|
1297
|
+
const indent = io.stdoutIsTty ? 2 : 0;
|
|
1298
|
+
if (mode === "raw") {
|
|
1299
|
+
const raws = decided.map((d) => d.raw);
|
|
1300
|
+
io.out(JSON.stringify(batch ? { results: raws } : raws[0], null, indent));
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (mode === "json") {
|
|
1304
|
+
const results = decided.map((d) => d.result);
|
|
1305
|
+
io.out(JSON.stringify(batch ? results : results[0], null, indent));
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (mode === "jsonl") {
|
|
1309
|
+
for (const d of decided) io.out(JSON.stringify(line(d, cap.envelope)));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (mode === "quiet") {
|
|
1313
|
+
for (const d of decided) for (const l of cap.quiet(d.result)) io.out(l);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const blocks = decided.map((d) => batch ? `${dim(head(d.text))}\n${cap.table(d.result, dim, names)}` : cap.table(d.result, dim, names));
|
|
1317
|
+
io.out(blocks.join("\n\n"));
|
|
1318
|
+
if (bare !== void 0 && cap.name === "classify") io.out(`\n Bare label names score worse. Try: ${bare}="charges and refunds"`);
|
|
1319
|
+
}
|
|
1320
|
+
const head = (text) => text.length > 72 ? `${text.slice(0, 71)}…` : text;
|
|
1321
|
+
/** One compact object per line, with the input text, so a batch stays joinable. */
|
|
1322
|
+
function line(d, env) {
|
|
1323
|
+
if (Array.isArray(d.result)) return {
|
|
1324
|
+
text: d.text,
|
|
1325
|
+
[env ?? "results"]: d.result
|
|
1326
|
+
};
|
|
1327
|
+
return {
|
|
1328
|
+
...d.result,
|
|
1329
|
+
text: d.text
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function usageLine(t) {
|
|
1333
|
+
const rate = t.rateLimit ? `, ${int(t.rateLimit.remainingRequests)}/${int(t.rateLimit.limitRequests)} requests left` : "";
|
|
1334
|
+
return `usage: ${int(t.chars)} chars, ${int(t.tokens)} tokens, ${int(t.ms)} ms inference${rate}`;
|
|
1335
|
+
}
|
|
1336
|
+
/** `--check`, `--min` and `--min-confidence`. The result still printed, so this only exits 3. */
|
|
1337
|
+
function failed(decided, v, min, minConfidence, cap) {
|
|
1338
|
+
if (v.check !== true && min === null && minConfidence === null) return false;
|
|
1339
|
+
for (const d of decided) {
|
|
1340
|
+
const list = items(d.result);
|
|
1341
|
+
if (v.check === true && list.length === 0) return true;
|
|
1342
|
+
for (const item of list) {
|
|
1343
|
+
if (v.check === true) {
|
|
1344
|
+
if (!(cap.name === "verify" ? item.matches === true : item.answer === true)) return true;
|
|
1345
|
+
}
|
|
1346
|
+
if (min !== null && typeof item.probability === "number" && item.probability < min) return true;
|
|
1347
|
+
if (minConfidence !== null && typeof item.confidence === "number" && item.confidence < minConfidence) return true;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
/** One small classify. It proves the key, the endpoint and the limits. */
|
|
1353
|
+
async function check(io, dm, v, color) {
|
|
1354
|
+
const started = Date.now();
|
|
1355
|
+
const { usage } = await dm.classify("ok", ["yes", "no"]).withUsage();
|
|
1356
|
+
const round = Date.now() - started;
|
|
1357
|
+
const key = v.key ?? io.env.MS_API_KEY ?? "";
|
|
1358
|
+
const masked = key.length > 16 ? `${key.slice(0, 12)}…${key.slice(-4)}` : key;
|
|
1359
|
+
const rl = usage.rateLimit;
|
|
1360
|
+
if (v.json === true || !io.stdoutIsTty) {
|
|
1361
|
+
io.out(JSON.stringify({
|
|
1362
|
+
key: masked,
|
|
1363
|
+
endpoint: dm.baseUrl,
|
|
1364
|
+
round_trip_ms: round,
|
|
1365
|
+
inference_ms: usage.inferenceMs,
|
|
1366
|
+
rate_limit: rl
|
|
1367
|
+
}, null, io.stdoutIsTty ? 2 : 0));
|
|
1368
|
+
return 0;
|
|
1369
|
+
}
|
|
1370
|
+
const limits = rl ? `${int(rl.limitRequests)} req/min` : "unknown";
|
|
1371
|
+
const remaining = rl ? `${int(rl.remainingRequests)} req` : "unknown";
|
|
1372
|
+
const width = Math.max(limits.length, remaining.length);
|
|
1373
|
+
const dim = dimmer(color);
|
|
1374
|
+
io.out(kv([
|
|
1375
|
+
["key", `${masked} valid`],
|
|
1376
|
+
["endpoint", dm.baseUrl],
|
|
1377
|
+
["latency", `${int(round)} ms round trip · ${int(usage.inferenceMs)} ms model`],
|
|
1378
|
+
["limits", rl ? `${limits.padEnd(width)} · ${int(rl.limitTokens)} tok/min` : "unknown"],
|
|
1379
|
+
["remaining", rl ? `${remaining.padEnd(width)} · ${int(rl.remainingTokens)} tok` : "unknown"]
|
|
1380
|
+
], dim, 10));
|
|
1381
|
+
io.out("ok");
|
|
1382
|
+
return 0;
|
|
1383
|
+
}
|
|
1384
|
+
//#endregion
|
|
1385
|
+
//#region src/cli/index.ts
|
|
1386
|
+
/** All of stdin. A pipe ends it; a terminal ends it on Ctrl-D. */
|
|
1387
|
+
async function stdin() {
|
|
1388
|
+
const parts = [];
|
|
1389
|
+
for await (const part of process.stdin) parts.push(part);
|
|
1390
|
+
return Buffer.concat(parts).toString("utf8");
|
|
1391
|
+
}
|
|
1392
|
+
process.exitCode = await run({
|
|
1393
|
+
argv: process.argv.slice(2),
|
|
1394
|
+
env: process.env,
|
|
1395
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
1396
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
1397
|
+
stdin,
|
|
1398
|
+
stdinIsTty: process.stdin.isTTY === true,
|
|
1399
|
+
stdoutIsTty: process.stdout.isTTY === true
|
|
1400
|
+
});
|
|
1401
|
+
//#endregion
|
|
1402
|
+
export {};
|