wity-cli 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 +55 -0
- package/README.md +186 -0
- package/dist/cli.mjs +509 -0
- package/dist/mcp-DnqXX8ba.mjs +183 -0
- package/dist/package-7FOUqcPO.mjs +4 -0
- package/dist/playground-Cn7EIrd7.mjs +389 -0
- package/dist/questions-eZ01WB1q.mjs +325 -0
- package/dist/send-CGXuVi9Z.mjs +1052 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import { format, stripVTControlCharacters, styleText } from "node:util";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region node_modules/wity-sdk/dist/index.mjs
|
|
7
|
+
/** Base class for all SDK errors. Also thrown for setup problems, like a missing key. */
|
|
8
|
+
var WityError = class extends Error {
|
|
9
|
+
name = "WityError";
|
|
10
|
+
};
|
|
11
|
+
const MOVED_HINT = "The API address may have changed. Set WITY_BASE_URL to the new address, or update the wity package.";
|
|
12
|
+
/** Extra advice added to the message for some statuses. */
|
|
13
|
+
const hintFor = (status) => {
|
|
14
|
+
if (status >= 300 && status < 400) return `The API answered with a redirect, which this SDK never follows. ${MOVED_HINT}`;
|
|
15
|
+
if (status === 401) return "Check WITY_API_KEY.";
|
|
16
|
+
if (status === 402) return "The account has no balance left.";
|
|
17
|
+
if (status === 404) return MOVED_HINT;
|
|
18
|
+
};
|
|
19
|
+
const MAX_TEXT_IN_MESSAGE = 200;
|
|
20
|
+
/** Turn a list of validation errors (`[{loc, msg}, ...]`) into "field: problem; field: problem". */
|
|
21
|
+
const describeList = (items) => {
|
|
22
|
+
const parts = items.flatMap((item) => {
|
|
23
|
+
if (typeof item !== "object" || item === null) return [];
|
|
24
|
+
const { loc, msg } = item;
|
|
25
|
+
if (typeof msg !== "string") return [];
|
|
26
|
+
const where = Array.isArray(loc) ? loc.filter((part) => part !== "body").join(".") : "";
|
|
27
|
+
return [where ? `${where}: ${msg}` : msg];
|
|
28
|
+
});
|
|
29
|
+
return parts.length > 0 ? parts.join("; ") : void 0;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Read the message out of an error body.
|
|
33
|
+
* Wity uses `{"error": "..."}` on some routes and `{"detail": "..."}` on others.
|
|
34
|
+
*/
|
|
35
|
+
const messageFrom = (body) => {
|
|
36
|
+
if (typeof body === "string") {
|
|
37
|
+
const text = body.trim();
|
|
38
|
+
if (text.startsWith("<")) return "the server sent an HTML error page";
|
|
39
|
+
return text.length > MAX_TEXT_IN_MESSAGE ? `${text.slice(0, MAX_TEXT_IN_MESSAGE)}…` : text || void 0;
|
|
40
|
+
}
|
|
41
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
42
|
+
const { error, detail, message } = body;
|
|
43
|
+
for (const value of [
|
|
44
|
+
error,
|
|
45
|
+
detail,
|
|
46
|
+
message
|
|
47
|
+
]) {
|
|
48
|
+
if (typeof value === "string") return value;
|
|
49
|
+
if (Array.isArray(value)) return describeList(value);
|
|
50
|
+
if (typeof value === "object" && value !== null) {
|
|
51
|
+
const inner = value.message;
|
|
52
|
+
if (typeof inner === "string") return inner;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** The id the server gave this request. Quote it when reporting a problem. */
|
|
57
|
+
const requestIdFrom = (headers) => headers.get("x-request-id") ?? headers.get("x-railway-request-id") ?? void 0;
|
|
58
|
+
/** The API answered with an error status. */
|
|
59
|
+
var APIError = class APIError extends WityError {
|
|
60
|
+
name = "APIError";
|
|
61
|
+
/** HTTP status code, for example 400. */
|
|
62
|
+
status;
|
|
63
|
+
/** The response body: parsed JSON, text, or `undefined` if empty. */
|
|
64
|
+
body;
|
|
65
|
+
/** The response headers, for example `Retry-After`. */
|
|
66
|
+
headers;
|
|
67
|
+
/** The server's id for this request, if it sent one. */
|
|
68
|
+
requestId;
|
|
69
|
+
/** `note` is extra context from the SDK, added to the end of the message. */
|
|
70
|
+
constructor(status, body, headers, note) {
|
|
71
|
+
const detail = messageFrom(body)?.replace(/\.+$/, "");
|
|
72
|
+
const head = detail ? `${status} ${detail}` : String(status);
|
|
73
|
+
const extra = [hintFor(status), note].filter(Boolean).join(" ");
|
|
74
|
+
super(extra ? `${head}. ${extra}` : head);
|
|
75
|
+
this.status = status;
|
|
76
|
+
this.body = body;
|
|
77
|
+
this.headers = headers;
|
|
78
|
+
this.requestId = requestIdFrom(headers);
|
|
79
|
+
}
|
|
80
|
+
/** Pick the error class that matches the status. */
|
|
81
|
+
static fromResponse(status, body, headers, note) {
|
|
82
|
+
if (status === 400) return new BadRequestError(status, body, headers, note);
|
|
83
|
+
if (status === 401) return new AuthenticationError(status, body, headers, note);
|
|
84
|
+
if (status === 402) return new InsufficientBalanceError(status, body, headers, note);
|
|
85
|
+
if (status === 429) return new RateLimitError(status, body, headers, note);
|
|
86
|
+
if (status >= 500) return new InternalServerError(status, body, headers, note);
|
|
87
|
+
return new APIError(status, body, headers, note);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** 400: the request is wrong. The message says which part. */
|
|
91
|
+
var BadRequestError = class extends APIError {
|
|
92
|
+
name = "BadRequestError";
|
|
93
|
+
};
|
|
94
|
+
/** 401: the API key is missing or wrong. */
|
|
95
|
+
var AuthenticationError = class extends APIError {
|
|
96
|
+
name = "AuthenticationError";
|
|
97
|
+
};
|
|
98
|
+
/** 402: the account has no balance left. */
|
|
99
|
+
var InsufficientBalanceError = class extends APIError {
|
|
100
|
+
name = "InsufficientBalanceError";
|
|
101
|
+
};
|
|
102
|
+
/** 429: too many requests. Thrown after the SDK's retries run out. */
|
|
103
|
+
var RateLimitError = class extends APIError {
|
|
104
|
+
name = "RateLimitError";
|
|
105
|
+
};
|
|
106
|
+
/** 5xx: a problem on Wity's side. Thrown after the SDK's retries run out. */
|
|
107
|
+
var InternalServerError = class extends APIError {
|
|
108
|
+
name = "InternalServerError";
|
|
109
|
+
};
|
|
110
|
+
/** The API couldn't be reached, or the connection broke while reading the answer. */
|
|
111
|
+
var APIConnectionError = class extends WityError {
|
|
112
|
+
name = "APIConnectionError";
|
|
113
|
+
};
|
|
114
|
+
/** The API didn't answer within the timeout. */
|
|
115
|
+
var APITimeoutError = class extends WityError {
|
|
116
|
+
name = "APITimeoutError";
|
|
117
|
+
};
|
|
118
|
+
/** You cancelled the request with its `signal`. */
|
|
119
|
+
var APIUserAbortError = class extends WityError {
|
|
120
|
+
name = "APIUserAbortError";
|
|
121
|
+
};
|
|
122
|
+
const LOG_LEVELS = [
|
|
123
|
+
"debug",
|
|
124
|
+
"info",
|
|
125
|
+
"warn",
|
|
126
|
+
"error",
|
|
127
|
+
"off"
|
|
128
|
+
];
|
|
129
|
+
const DEFAULT_LOG_LEVEL = "warn";
|
|
130
|
+
/** Check a level from the `logLevel` option or WITY_LOG_LEVEL. */
|
|
131
|
+
const parseLogLevel = (value, source) => {
|
|
132
|
+
const level = value.trim().toLowerCase();
|
|
133
|
+
if (LOG_LEVELS.includes(level)) return level;
|
|
134
|
+
throw new WityError(`${source} must be one of: ${LOG_LEVELS.join(", ")}.`);
|
|
135
|
+
};
|
|
136
|
+
const PREFIX = "[wity]";
|
|
137
|
+
/** The default logger: the console, with a `[wity]` prefix. */
|
|
138
|
+
const consoleLogger = {
|
|
139
|
+
debug: (message, ...args) => console.debug(`${PREFIX} ${message}`, ...args),
|
|
140
|
+
info: (message, ...args) => console.info(`${PREFIX} ${message}`, ...args),
|
|
141
|
+
warn: (message, ...args) => console.warn(`${PREFIX} ${message}`, ...args),
|
|
142
|
+
error: (message, ...args) => console.error(`${PREFIX} ${message}`, ...args)
|
|
143
|
+
};
|
|
144
|
+
const RANK = {
|
|
145
|
+
debug: 0,
|
|
146
|
+
info: 1,
|
|
147
|
+
warn: 2,
|
|
148
|
+
error: 3,
|
|
149
|
+
off: 4
|
|
150
|
+
};
|
|
151
|
+
const drop = () => {};
|
|
152
|
+
/** Pass on only messages at `level` or above. */
|
|
153
|
+
const withLevel = (sink, level) => {
|
|
154
|
+
const on = (at) => RANK[at] >= RANK[level];
|
|
155
|
+
return {
|
|
156
|
+
debug: on("debug") ? (message, ...args) => sink.debug(message, ...args) : drop,
|
|
157
|
+
info: on("info") ? (message, ...args) => sink.info(message, ...args) : drop,
|
|
158
|
+
warn: on("warn") ? (message, ...args) => sink.warn(message, ...args) : drop,
|
|
159
|
+
error: on("error") ? (message, ...args) => sink.error(message, ...args) : drop
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
/** Statuses worth retrying: 408 (timeout), 429 (too many requests) and 5xx (Wity's side). */
|
|
163
|
+
const shouldRetryStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
164
|
+
/** Read `Retry-After` as milliseconds. It can be seconds ("1") or a date. */
|
|
165
|
+
const retryAfterMs = (headers) => {
|
|
166
|
+
const raw = headers.get("retry-after");
|
|
167
|
+
if (raw === null) return void 0;
|
|
168
|
+
const seconds = Number(raw);
|
|
169
|
+
if (raw.trim() !== "" && Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
170
|
+
const date = Date.parse(raw);
|
|
171
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
172
|
+
};
|
|
173
|
+
/** Wait 0.5 s, then 1 s, 2 s... up to 8 s. The random part stops many clients retrying at the same moment. */
|
|
174
|
+
const backoffMs = (attempt) => Math.min(500 * 2 ** attempt, 8e3) * (.75 + Math.random() * .25);
|
|
175
|
+
/** How long to wait before the next try, or `undefined` to stop retrying. */
|
|
176
|
+
const delayBeforeRetry = (attempt, headers) => {
|
|
177
|
+
const fromServer = retryAfterMs(headers);
|
|
178
|
+
if (fromServer === void 0) return backoffMs(attempt);
|
|
179
|
+
return fromServer <= 6e4 ? fromServer : void 0;
|
|
180
|
+
};
|
|
181
|
+
/** Wait `ms`. If `signal` fires first, stop waiting and reject with its reason. */
|
|
182
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
183
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
184
|
+
const onAbort = () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
reject(signal?.reason);
|
|
187
|
+
};
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
signal?.removeEventListener("abort", onAbort);
|
|
190
|
+
resolve();
|
|
191
|
+
}, ms);
|
|
192
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
193
|
+
});
|
|
194
|
+
/**
|
|
195
|
+
* The production API address.
|
|
196
|
+
* If it ever moves: change this line, bump the version and publish.
|
|
197
|
+
* Until users update, they can set WITY_BASE_URL or the `baseURL` option.
|
|
198
|
+
*/
|
|
199
|
+
const DEFAULT_BASE_URL = "https://wity-proxy-production-2c33.up.railway.app";
|
|
200
|
+
/** Extra time on top of `max_latency_ms` for the network and Wity's own work. */
|
|
201
|
+
const LATENCY_MARGIN_MS = 1e4;
|
|
202
|
+
/** The longest delay `setTimeout` accepts. A longer one fires at once. */
|
|
203
|
+
const MAX_TIMER_MS = 2147483647;
|
|
204
|
+
/** Wity's answers are a few kB. A body far bigger than that isn't a Wity answer, so the SDK stops reading. */
|
|
205
|
+
const MAX_BODY_BYTES = 10485760;
|
|
206
|
+
/**
|
|
207
|
+
* Top-level fields each route accepts, from the API docs. `model` is accepted and ignored by Wity.
|
|
208
|
+
* Other fields are still sent, so new API fields work without an SDK update, but the SDK logs a warning.
|
|
209
|
+
*/
|
|
210
|
+
const KNOWN_FIELDS = {
|
|
211
|
+
"/v1/systemone": /* @__PURE__ */ new Set([
|
|
212
|
+
"state",
|
|
213
|
+
"questions",
|
|
214
|
+
"reasoning",
|
|
215
|
+
"max_latency_ms",
|
|
216
|
+
"image",
|
|
217
|
+
"model"
|
|
218
|
+
]),
|
|
219
|
+
"/v1/generate": /* @__PURE__ */ new Set([
|
|
220
|
+
"state",
|
|
221
|
+
"instructions",
|
|
222
|
+
"shape",
|
|
223
|
+
"max_tokens",
|
|
224
|
+
"image",
|
|
225
|
+
"model"
|
|
226
|
+
])
|
|
227
|
+
};
|
|
228
|
+
const readEnv = (name) => {
|
|
229
|
+
if (typeof process === "undefined" || !process.env) return void 0;
|
|
230
|
+
return process.env[name]?.trim() || void 0;
|
|
231
|
+
};
|
|
232
|
+
/** A browser page, or a browser worker. Server runtimes have neither. */
|
|
233
|
+
const inBrowser = () => {
|
|
234
|
+
const g = globalThis;
|
|
235
|
+
const page = typeof g.window !== "undefined" && typeof g.document !== "undefined";
|
|
236
|
+
const worker = typeof g.WorkerGlobalScope !== "undefined" && typeof g.importScripts === "function";
|
|
237
|
+
return page || worker;
|
|
238
|
+
};
|
|
239
|
+
/** Printable characters only, no spaces. */
|
|
240
|
+
const KEY_PATTERN = /^[\x21-\x7E]+$/;
|
|
241
|
+
const LOCAL_HOSTS = /* @__PURE__ */ new Set([
|
|
242
|
+
"localhost",
|
|
243
|
+
"127.0.0.1",
|
|
244
|
+
"[::1]"
|
|
245
|
+
]);
|
|
246
|
+
/**
|
|
247
|
+
* Check the base URL and return it without a trailing slash.
|
|
248
|
+
* Error messages never repeat the URL, in case a key was pasted into it by mistake.
|
|
249
|
+
*/
|
|
250
|
+
const checkBaseURL = (raw) => {
|
|
251
|
+
let url;
|
|
252
|
+
try {
|
|
253
|
+
url = new URL(raw);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new WityError("The Wity base URL is not a valid URL. Check WITY_BASE_URL or the baseURL option.");
|
|
256
|
+
}
|
|
257
|
+
const isLocalHttp = url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
|
|
258
|
+
if (url.protocol !== "https:" && !isLocalHttp) throw new WityError("The Wity base URL must start with https:// so the API key is encrypted on the way. Plain http:// is only allowed for localhost.");
|
|
259
|
+
if (url.username || url.password) throw new WityError("The Wity base URL must not contain a username or password.");
|
|
260
|
+
if (url.search || url.hash) throw new WityError("The Wity base URL must not contain ? or #.");
|
|
261
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
262
|
+
};
|
|
263
|
+
/** Parse a body as JSON, or keep it as text. `undefined` when empty. */
|
|
264
|
+
const readBody = async (response) => {
|
|
265
|
+
let text = "";
|
|
266
|
+
if (response.body) {
|
|
267
|
+
const reader = response.body.getReader();
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
let bytes = 0;
|
|
270
|
+
for (;;) {
|
|
271
|
+
const { done, value } = await reader.read();
|
|
272
|
+
if (done) break;
|
|
273
|
+
bytes += value.byteLength;
|
|
274
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
275
|
+
await reader.cancel();
|
|
276
|
+
throw new WityError(`The answer is larger than ${MAX_BODY_BYTES / 1024 / 1024} MB, so it can't be from Wity. Check WITY_BASE_URL or the baseURL option.`);
|
|
277
|
+
}
|
|
278
|
+
text += decoder.decode(value, { stream: true });
|
|
279
|
+
}
|
|
280
|
+
text += decoder.decode();
|
|
281
|
+
}
|
|
282
|
+
if (text === "") return void 0;
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(text);
|
|
285
|
+
} catch {
|
|
286
|
+
return text;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
/** Plain JavaScript can pass anything. Fail with a WityError before reading fields off it. */
|
|
290
|
+
const checkRequest = (request) => {
|
|
291
|
+
if (typeof request !== "object" || request === null || Array.isArray(request)) throw new WityError("The request must be an object, for example { state, questions }.");
|
|
292
|
+
};
|
|
293
|
+
/** Client for the Wity API. Use it on a server only: it holds your API key. */
|
|
294
|
+
var WityClient = class {
|
|
295
|
+
/** The address requests go to. Useful to check which API you're calling. */
|
|
296
|
+
baseURL;
|
|
297
|
+
timeoutMs;
|
|
298
|
+
maxRetries;
|
|
299
|
+
logLevel;
|
|
300
|
+
#apiKey;
|
|
301
|
+
#fetch;
|
|
302
|
+
#log;
|
|
303
|
+
constructor(options = {}) {
|
|
304
|
+
if (inBrowser()) throw new WityError("WityClient can't run in a browser. Your API key would be visible to anyone using the page. Call Wity from your server instead.");
|
|
305
|
+
const apiKey = options.apiKey !== void 0 ? options.apiKey.trim() : readEnv("WITY_API_KEY");
|
|
306
|
+
if (options.apiKey !== void 0 && !apiKey) throw new WityError("The apiKey option is empty. Pass a real key, or leave apiKey out to use WITY_API_KEY.");
|
|
307
|
+
if (!apiKey) throw new WityError("No API key found. Set the WITY_API_KEY environment variable, or pass apiKey to WityClient.");
|
|
308
|
+
if (!KEY_PATTERN.test(apiKey)) throw new WityError("The API key contains spaces or unusual characters. Check WITY_API_KEY.");
|
|
309
|
+
this.#apiKey = apiKey;
|
|
310
|
+
this.baseURL = checkBaseURL(options.baseURL ?? readEnv("WITY_BASE_URL") ?? "https://wity-proxy-production-2c33.up.railway.app");
|
|
311
|
+
this.timeoutMs = options.timeoutMs ?? 6e4;
|
|
312
|
+
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > MAX_TIMER_MS) throw new WityError(`timeoutMs must be a positive number of milliseconds, at most ${MAX_TIMER_MS}.`);
|
|
313
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
314
|
+
if (!Number.isInteger(this.maxRetries) || this.maxRetries < 0) throw new WityError("maxRetries must be a whole number, 0 or more.");
|
|
315
|
+
const levelFromEnv = readEnv("WITY_LOG_LEVEL");
|
|
316
|
+
this.logLevel = options.logLevel !== void 0 ? parseLogLevel(options.logLevel, "The logLevel option") : levelFromEnv !== void 0 ? parseLogLevel(levelFromEnv, "WITY_LOG_LEVEL") : DEFAULT_LOG_LEVEL;
|
|
317
|
+
this.#log = withLevel(options.logger ?? consoleLogger, this.logLevel);
|
|
318
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Ask one or more typed questions about `state`.
|
|
322
|
+
* Answers come back under the same names as the questions.
|
|
323
|
+
*/
|
|
324
|
+
async systemOne(request, options = {}) {
|
|
325
|
+
checkRequest(request);
|
|
326
|
+
const latency = request.max_latency_ms;
|
|
327
|
+
const summary = {
|
|
328
|
+
questions: Object.keys(request.questions ?? {}),
|
|
329
|
+
reasoning: request.reasoning ?? "default"
|
|
330
|
+
};
|
|
331
|
+
return await this.#post("/v1/systemone", request, summary, {
|
|
332
|
+
signal: options.signal,
|
|
333
|
+
minTimeoutMs: Number.isFinite(latency) ? latency + LATENCY_MARGIN_MS : 0
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Write short text from `state`, or JSON that matches `shape`.
|
|
338
|
+
* Pass the type of `value` yourself if you like: `generate<{ city: string }>(...)`.
|
|
339
|
+
*/
|
|
340
|
+
async generate(request, options = {}) {
|
|
341
|
+
checkRequest(request);
|
|
342
|
+
const summary = {
|
|
343
|
+
shape: request.shape !== void 0,
|
|
344
|
+
max_tokens: request.max_tokens ?? "default"
|
|
345
|
+
};
|
|
346
|
+
return await this.#post("/v1/generate", request, summary, {
|
|
347
|
+
signal: options.signal,
|
|
348
|
+
minTimeoutMs: 0
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
async #post(route, request, summary, { signal, minTimeoutMs }) {
|
|
352
|
+
const body = Object.fromEntries(Object.entries(request).filter(([, value]) => value !== void 0));
|
|
353
|
+
const unknown = Object.keys(body).filter((field) => !KNOWN_FIELDS[route].has(field)).map((field) => field.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "?"));
|
|
354
|
+
const unknownNote = unknown.length > 0 ? `The request included fields Wity may not know: ${unknown.join(", ")}.` : void 0;
|
|
355
|
+
if (unknownNote) this.#log.warn(`${route}: ${unknownNote} Check for typos.`);
|
|
356
|
+
const url = `${this.baseURL}${route}`;
|
|
357
|
+
let payload;
|
|
358
|
+
try {
|
|
359
|
+
payload = JSON.stringify(body);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new WityError("The request can't be turned into JSON. Look for BigInt values or loops in state.", { cause: err });
|
|
362
|
+
}
|
|
363
|
+
const timeoutMs = Math.min(Math.max(this.timeoutMs, minTimeoutMs), MAX_TIMER_MS);
|
|
364
|
+
const attempts = this.maxRetries + 1;
|
|
365
|
+
for (let attempt = 0;; attempt++) {
|
|
366
|
+
const canRetry = attempt < this.maxRetries;
|
|
367
|
+
const retryLabel = `retry ${attempt + 1} of ${this.maxRetries}`;
|
|
368
|
+
this.#log.debug(`${route}: sending (attempt ${attempt + 1} of ${attempts})`, {
|
|
369
|
+
url,
|
|
370
|
+
bytes: new TextEncoder().encode(payload).length,
|
|
371
|
+
...summary
|
|
372
|
+
});
|
|
373
|
+
const started = Date.now();
|
|
374
|
+
let response;
|
|
375
|
+
let data;
|
|
376
|
+
try {
|
|
377
|
+
({response, data} = await this.#send(url, payload, timeoutMs, signal));
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const ms = Date.now() - started;
|
|
380
|
+
if (err instanceof APIConnectionError && canRetry) {
|
|
381
|
+
const delay = Math.round(backoffMs(attempt));
|
|
382
|
+
this.#log.info(`${route}: connection failed after ${ms} ms. Retrying in ${delay} ms (${retryLabel}).`, err.cause);
|
|
383
|
+
await this.#wait(delay, signal);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
this.#log.info(`${route}: ${err.message} (after ${ms} ms)`);
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
const requestId = requestIdFrom(response.headers);
|
|
390
|
+
this.#log.debug(`${route}: got ${response.status} in ${Date.now() - started} ms`, { requestId });
|
|
391
|
+
if (response.ok) {
|
|
392
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new WityError("Wity answered with something that isn't a JSON object.");
|
|
393
|
+
return data;
|
|
394
|
+
}
|
|
395
|
+
if (canRetry && shouldRetryStatus(response.status)) {
|
|
396
|
+
const delay = delayBeforeRetry(attempt, response.headers);
|
|
397
|
+
if (delay !== void 0) {
|
|
398
|
+
this.#log.info(`${route}: got ${response.status}. Retrying in ${Math.round(delay)} ms (${retryLabel}).`, { requestId });
|
|
399
|
+
await this.#wait(delay, signal);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
this.#log.warn(`${route}: got ${response.status}, and Retry-After asks for more than 60 s. Not retrying.`, { requestId });
|
|
403
|
+
}
|
|
404
|
+
throw APIError.fromResponse(response.status, data, response.headers, response.status === 400 ? unknownNote : void 0);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* One attempt: send the request and read the whole answer.
|
|
409
|
+
* One timer and the caller's signal cover both steps, so a stalled or broken
|
|
410
|
+
* answer still ends in a WityError.
|
|
411
|
+
*/
|
|
412
|
+
async #send(url, payload, timeoutMs, signal) {
|
|
413
|
+
if (signal?.aborted) throw new APIUserAbortError("The request was cancelled.", { cause: signal.reason });
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
let timedOut = false;
|
|
416
|
+
const timer = setTimeout(() => {
|
|
417
|
+
timedOut = true;
|
|
418
|
+
controller.abort();
|
|
419
|
+
}, timeoutMs);
|
|
420
|
+
const onCallerAbort = () => controller.abort();
|
|
421
|
+
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
422
|
+
try {
|
|
423
|
+
const response = await this.#fetch(url, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: {
|
|
426
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
Accept: "application/json"
|
|
429
|
+
},
|
|
430
|
+
body: payload,
|
|
431
|
+
redirect: "manual",
|
|
432
|
+
signal: controller.signal
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
response,
|
|
436
|
+
data: await readBody(response)
|
|
437
|
+
};
|
|
438
|
+
} catch (err) {
|
|
439
|
+
if (err instanceof WityError) throw err;
|
|
440
|
+
if (timedOut) throw new APITimeoutError(`Wity didn't answer within ${timeoutMs} ms.`, { cause: err });
|
|
441
|
+
if (signal?.aborted) throw new APIUserAbortError("The request was cancelled.", { cause: err });
|
|
442
|
+
throw new APIConnectionError(`Couldn't reach the Wity API at ${this.baseURL}, or the connection broke while reading its answer.`, { cause: err });
|
|
443
|
+
} finally {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
signal?.removeEventListener("abort", onCallerAbort);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Wait between retries. Cancelling through `signal` stops the wait. */
|
|
449
|
+
async #wait(ms, signal) {
|
|
450
|
+
try {
|
|
451
|
+
await sleep(ms, signal);
|
|
452
|
+
} catch (reason) {
|
|
453
|
+
this.#log.info("Cancelled while waiting to retry.");
|
|
454
|
+
throw new APIUserAbortError("The request was cancelled.", { cause: reason });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
/**
|
|
459
|
+
* Pick one option.
|
|
460
|
+
*
|
|
461
|
+
* @example choice("Which team?", { billing: "Payments and refunds", technical: "Bugs" })
|
|
462
|
+
*/
|
|
463
|
+
const choice = (instructions, criteria) => ({
|
|
464
|
+
type: "choice",
|
|
465
|
+
instructions,
|
|
466
|
+
criteria
|
|
467
|
+
});
|
|
468
|
+
/**
|
|
469
|
+
* Yes or no. Optionally describe what yes and no mean.
|
|
470
|
+
*
|
|
471
|
+
* @example noul("Is this urgent?", { true: "Needs action today", false: "Can wait" })
|
|
472
|
+
*/
|
|
473
|
+
const noul = (instructions, criteria) => criteria === void 0 ? {
|
|
474
|
+
type: "noul",
|
|
475
|
+
instructions
|
|
476
|
+
} : {
|
|
477
|
+
type: "noul",
|
|
478
|
+
instructions,
|
|
479
|
+
criteria
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
482
|
+
* Pick a level on a scale of 2 to 10 levels, lowest first.
|
|
483
|
+
* Levels can come from a variable, for example a list loaded from a database.
|
|
484
|
+
*
|
|
485
|
+
* @example score("How upset is the customer?", ["Calm", "Annoyed", "Angry"])
|
|
486
|
+
*/
|
|
487
|
+
const score = (instructions, criteria) => ({
|
|
488
|
+
type: "score",
|
|
489
|
+
instructions,
|
|
490
|
+
criteria
|
|
491
|
+
});
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/exit.ts
|
|
494
|
+
const EXIT = {
|
|
495
|
+
ok: 0,
|
|
496
|
+
/** The API, the network or something else failed. */
|
|
497
|
+
error: 1,
|
|
498
|
+
/** Bad flags or bad input. Nothing was sent, or Wity refused the request (400). Nothing was billed. */
|
|
499
|
+
usage: 2,
|
|
500
|
+
/** No key, or the key was refused. */
|
|
501
|
+
auth: 3,
|
|
502
|
+
/** The answer came back, but it failed `--fail-under` or `--expect`. */
|
|
503
|
+
gate: 10,
|
|
504
|
+
/** Ctrl+C. */
|
|
505
|
+
cancelled: 130
|
|
506
|
+
};
|
|
507
|
+
/** An error meant for the person at the terminal. `message` is printed as is, so write it for them. */
|
|
508
|
+
var CliError = class extends Error {
|
|
509
|
+
name = "CliError";
|
|
510
|
+
code;
|
|
511
|
+
/** One extra line on what to do next. */
|
|
512
|
+
hint;
|
|
513
|
+
constructor(message, code = EXIT.error, hint) {
|
|
514
|
+
super(message);
|
|
515
|
+
this.code = code;
|
|
516
|
+
this.hint = hint;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/config.ts
|
|
521
|
+
/** The website, where people sign in and manage keys. */
|
|
522
|
+
const CONSOLE_URL = "https://wity.alphanimble.com";
|
|
523
|
+
const KEYS_PAGE = `${CONSOLE_URL}/console/keys`;
|
|
524
|
+
const BILLING_PAGE = `${CONSOLE_URL}/console/billing`;
|
|
525
|
+
/** Dollars per million input tokens, from wity.alphanimble.com/pricing. Output and thinking are free. */
|
|
526
|
+
const PRICE_PER_MILLION_INPUT_USD = .042;
|
|
527
|
+
/**
|
|
528
|
+
* The API address: `--base-url`, then WITY_BASE_URL, then the SDK's default.
|
|
529
|
+
* Returned in the same form the SDK uses (no trailing slash), because saved keys are filed under it.
|
|
530
|
+
* The SDK does the full check (https only, no user or password) when the client is made.
|
|
531
|
+
*/
|
|
532
|
+
const resolveBaseURL = (flag, env) => {
|
|
533
|
+
const raw = flag?.trim() || env.WITY_BASE_URL?.trim() || "https://wity-proxy-production-2c33.up.railway.app";
|
|
534
|
+
let url;
|
|
535
|
+
try {
|
|
536
|
+
url = new URL(raw);
|
|
537
|
+
} catch {
|
|
538
|
+
throw new CliError("The API address is not a valid URL. Check --base-url or WITY_BASE_URL.", EXIT.usage);
|
|
539
|
+
}
|
|
540
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
541
|
+
};
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/output.ts
|
|
544
|
+
/** NO_COLOR and TERM=dumb turn colour off. FORCE_COLOR turns it on. Otherwise only on a terminal. */
|
|
545
|
+
const shouldColor = (env, stream) => {
|
|
546
|
+
if (env.NO_COLOR) return false;
|
|
547
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true;
|
|
548
|
+
if (env.TERM === "dumb") return false;
|
|
549
|
+
return Boolean(stream.isTTY);
|
|
550
|
+
};
|
|
551
|
+
const painter = (on) => (style, text) => on ? styleText(style, text, { validateStream: false }) : text;
|
|
552
|
+
/** Spinners and other redrawing. Off in CI and when output isn't a terminal. */
|
|
553
|
+
const canAnimate = (io, stream) => Boolean(stream.isTTY) && !io.env.CI;
|
|
554
|
+
/** An OSC sequence (window title, hyperlink): ESC ] ... ended by BEL or ESC \. Node's strip leaves its text behind. */
|
|
555
|
+
const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
|
|
556
|
+
/**
|
|
557
|
+
* Text from the API or from a file, made safe for one line of a terminal.
|
|
558
|
+
* Escape codes and control characters are removed, so text can't move the cursor, change colours or fake output.
|
|
559
|
+
*/
|
|
560
|
+
const clean = (text) => stripVTControlCharacters(text.replace(OSC, "")).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, " ").trim();
|
|
561
|
+
/** Like `clean`, for text of many lines: line breaks and tabs stay, everything else that could act on a terminal goes. */
|
|
562
|
+
const cleanBlock = (text) => stripVTControlCharacters(text.replace(OSC, "")).replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(/[\p{Cc}\p{Cf}]/gu, (ch) => ch === "\n" || ch === " " ? ch : "");
|
|
563
|
+
/** Shorten to `max` characters, with an ellipsis. */
|
|
564
|
+
const truncate = (text, max) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
565
|
+
/**
|
|
566
|
+
* A probability as a percentage with 2 decimals.
|
|
567
|
+
* Values that would print as 0.00% or 100.00% print as <0.01% or >99.99%, so nothing looks certain when it isn't.
|
|
568
|
+
*/
|
|
569
|
+
const pct = (p) => {
|
|
570
|
+
if (p <= 0) return "0%";
|
|
571
|
+
if (p >= 1) return "100%";
|
|
572
|
+
if (p < 5e-5) return "<0.01%";
|
|
573
|
+
if (p >= .99995) return ">99.99%";
|
|
574
|
+
return `${(p * 100).toFixed(2)}%`;
|
|
575
|
+
};
|
|
576
|
+
const PARTS = [
|
|
577
|
+
"",
|
|
578
|
+
"▏",
|
|
579
|
+
"▎",
|
|
580
|
+
"▍",
|
|
581
|
+
"▌",
|
|
582
|
+
"▋",
|
|
583
|
+
"▊",
|
|
584
|
+
"▉"
|
|
585
|
+
];
|
|
586
|
+
/** A bar `width` cells wide, filled in eighths of a cell. */
|
|
587
|
+
const bar = (p, width, paint, style) => {
|
|
588
|
+
const eighths = Math.round(Math.min(1, Math.max(0, p)) * width * 8);
|
|
589
|
+
const full = Math.floor(eighths / 8);
|
|
590
|
+
const part = PARTS[eighths % 8] ?? "";
|
|
591
|
+
const filled = "█".repeat(full) + part;
|
|
592
|
+
const empty = width - full - (part ? 1 : 0);
|
|
593
|
+
return (filled ? paint(style, filled) : "") + (empty > 0 ? paint("dim", "·".repeat(empty)) : "");
|
|
594
|
+
};
|
|
595
|
+
/** A 0–1 value like confidence, with 3 decimals. Like `pct`, it never rounds to exactly 0 or 1. */
|
|
596
|
+
const decimal = (value) => {
|
|
597
|
+
if (value > 0 && value < 5e-4) return "<0.001";
|
|
598
|
+
if (value < 1 && value >= .9995) return ">0.999";
|
|
599
|
+
return value.toFixed(3);
|
|
600
|
+
};
|
|
601
|
+
/** Estimated cost of `tokens` input tokens, like "$0.000012". The bill itself is worked out by Wity. */
|
|
602
|
+
const cost = (tokens) => {
|
|
603
|
+
const usd = tokens * PRICE_PER_MILLION_INPUT_USD / 1e6;
|
|
604
|
+
if (usd === 0) return "$0";
|
|
605
|
+
const decimals = usd >= .01 ? 2 : -Math.floor(Math.log10(usd)) + 1;
|
|
606
|
+
return `$${usd.toFixed(decimals)}`;
|
|
607
|
+
};
|
|
608
|
+
const ms = (value) => value >= 1e4 ? `${(value / 1e3).toFixed(1)} s` : `${Math.round(value)} ms`;
|
|
609
|
+
const writeJson = (io, value) => {
|
|
610
|
+
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
611
|
+
};
|
|
612
|
+
const FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
613
|
+
/**
|
|
614
|
+
* A spinner on stderr while a call runs. After `slowAfterMs` the text changes to `slowText`, if given.
|
|
615
|
+
* `text` can be a function, for a label that changes, like a countdown.
|
|
616
|
+
* Returns a function that clears it. Does nothing when stderr isn't a terminal or in CI.
|
|
617
|
+
*/
|
|
618
|
+
const spinner = (io, text, slowText, slowAfterMs = 1500) => {
|
|
619
|
+
if (!canAnimate(io, io.stderr)) return () => {};
|
|
620
|
+
const paint = painter(shouldColor(io.env, io.stderr));
|
|
621
|
+
const started = Date.now();
|
|
622
|
+
let frame = 0;
|
|
623
|
+
const draw = () => {
|
|
624
|
+
const label = slowText && Date.now() - started > slowAfterMs ? slowText : typeof text === "string" ? text : text();
|
|
625
|
+
io.stderr.write(`\r\x1b[2K ${paint("cyan", FRAMES[frame++ % 10] ?? "")} ${paint("dim", label)}`);
|
|
626
|
+
};
|
|
627
|
+
io.stderr.write("\x1B[?25l");
|
|
628
|
+
draw();
|
|
629
|
+
const timer = setInterval(draw, 80);
|
|
630
|
+
return () => {
|
|
631
|
+
clearInterval(timer);
|
|
632
|
+
io.stderr.write("\r\x1B[2K\x1B[?25h");
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/api.ts
|
|
637
|
+
/** How long the CLI's own checks (health, key) wait. API calls use the SDK's timeouts. */
|
|
638
|
+
const CHECK_TIMEOUT_MS = 15e3;
|
|
639
|
+
/**
|
|
640
|
+
* A client for `baseURL`. The SDK checks the address here: https only, plain http only for localhost.
|
|
641
|
+
* Its log lines go to stderr, so stdout stays clean for JSON.
|
|
642
|
+
*/
|
|
643
|
+
const makeClient = (key, baseURL, io) => {
|
|
644
|
+
try {
|
|
645
|
+
return new WityClient({
|
|
646
|
+
apiKey: key,
|
|
647
|
+
baseURL,
|
|
648
|
+
logLevel: io.env.WITY_LOG_LEVEL?.trim() ? io.env.WITY_LOG_LEVEL.trim() : void 0,
|
|
649
|
+
logger: {
|
|
650
|
+
debug: (message, ...args) => io.stderr.write(`${format(`[wity] ${message}`, ...args)}\n`),
|
|
651
|
+
info: (message, ...args) => io.stderr.write(`${format(`[wity] ${message}`, ...args)}\n`),
|
|
652
|
+
warn: (message, ...args) => io.stderr.write(`${format(`[wity] ${message}`, ...args)}\n`),
|
|
653
|
+
error: (message, ...args) => io.stderr.write(`${format(`[wity] ${message}`, ...args)}\n`)
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err instanceof WityError) throw new CliError(err.message, EXIT.usage);
|
|
658
|
+
throw err;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
/**
|
|
662
|
+
* A GET that never follows redirects, so a key only goes to the address it was meant for.
|
|
663
|
+
* Throws a CliError if the address can't be reached.
|
|
664
|
+
*/
|
|
665
|
+
const get = async (url, io, key) => {
|
|
666
|
+
const started = performance.now();
|
|
667
|
+
try {
|
|
668
|
+
const response = await fetch(url, {
|
|
669
|
+
headers: key ? { Authorization: `Bearer ${key}` } : {},
|
|
670
|
+
redirect: "manual",
|
|
671
|
+
signal: AbortSignal.any([io.signal, AbortSignal.timeout(CHECK_TIMEOUT_MS)])
|
|
672
|
+
});
|
|
673
|
+
await response.body?.cancel();
|
|
674
|
+
return {
|
|
675
|
+
status: response.status,
|
|
676
|
+
ms: performance.now() - started
|
|
677
|
+
};
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (io.signal.aborted) throw new CliError("Cancelled.", EXIT.cancelled);
|
|
680
|
+
throw new CliError(err instanceof Error && err.name === "TimeoutError" ? `No answer from Wity within ${CHECK_TIMEOUT_MS / 1e3} s.` : "Couldn't reach Wity.", EXIT.error, "Check your internet connection. `wity doctor` runs a full check.");
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
/** GET /health. Needs no key. */
|
|
684
|
+
const checkHealth = (baseURL, io) => get(`${baseURL}/health`, io);
|
|
685
|
+
/**
|
|
686
|
+
* GET /v1/models with the key. 200 means the key works, 401 means it was refused.
|
|
687
|
+
* This call isn't billed, so it's safe to run on every `api-key set` and `api-key show`.
|
|
688
|
+
*/
|
|
689
|
+
const checkKey = (key, baseURL, io) => {
|
|
690
|
+
const client = makeClient(key, baseURL, io);
|
|
691
|
+
return get(`${client.baseURL}/v1/models`, io, key);
|
|
692
|
+
};
|
|
693
|
+
/** What to do when a key is refused, depending on where it came from. */
|
|
694
|
+
const refusedHint = (found) => found.source === "env" ? "The key comes from WITY_API_KEY. Check it, or unset it to use the key saved by `wity api-key set`." : "It may have been revoked. Run `wity api-key set` to save a new key.";
|
|
695
|
+
/** Turn an SDK error into a message and exit code for the terminal. */
|
|
696
|
+
const explain = (err, found) => {
|
|
697
|
+
if (!(err instanceof WityError)) return err;
|
|
698
|
+
const id = err instanceof APIError && err.requestId ? ` Request id: ${clean(err.requestId)}` : "";
|
|
699
|
+
if (err instanceof AuthenticationError) return new CliError("Wity refused the API key.", EXIT.auth, refusedHint(found));
|
|
700
|
+
if (err instanceof InsufficientBalanceError) return new CliError("Your Wity balance has run out.", EXIT.error, `Add credits at ${BILLING_PAGE}`);
|
|
701
|
+
if (err instanceof BadRequestError) return new CliError(`Wity refused the request: ${clean(err.message)}`, EXIT.usage, `Nothing was billed.${id}`);
|
|
702
|
+
if (err instanceof RateLimitError) return new CliError("Too many requests at once for this key.", EXIT.error, `Wait a moment and try again.${id}`);
|
|
703
|
+
if (err instanceof APIUserAbortError) return new CliError("Cancelled.", EXIT.cancelled);
|
|
704
|
+
if (err instanceof APITimeoutError) return new CliError("Wity didn't answer in time.", EXIT.error, "Try again, or set --max-latency to cap thinking time.");
|
|
705
|
+
if (err instanceof APIConnectionError) return new CliError("Couldn't reach Wity.", EXIT.error, "Check your internet connection. `wity doctor` runs a full check.");
|
|
706
|
+
if (err instanceof APIError) return new CliError(`Wity answered with an error: ${clean(err.message)}`, EXIT.error, id.trim() || void 0);
|
|
707
|
+
return new CliError(clean(err.message), EXIT.usage);
|
|
708
|
+
};
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region src/credentials.ts
|
|
711
|
+
const SERVICE = "wity-cli";
|
|
712
|
+
/** Checks a stored record, so a hand-edited or broken one is reported instead of used. */
|
|
713
|
+
const parseSaved = (value, baseURL) => {
|
|
714
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
715
|
+
const { key, savedAt, via } = value;
|
|
716
|
+
if (typeof key !== "string" || key === "") return void 0;
|
|
717
|
+
return {
|
|
718
|
+
key,
|
|
719
|
+
baseURL,
|
|
720
|
+
savedAt: typeof savedAt === "string" ? savedAt : "",
|
|
721
|
+
via: via === "argument" || via === "token" ? via : "paste"
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
/** What goes into the keychain or the file. The address is the entry's name, so it isn't repeated inside. */
|
|
725
|
+
const record = ({ key, savedAt, via }) => ({
|
|
726
|
+
key,
|
|
727
|
+
savedAt,
|
|
728
|
+
via
|
|
729
|
+
});
|
|
730
|
+
const KEYCHAIN_NAMES = {
|
|
731
|
+
darwin: "macOS Keychain",
|
|
732
|
+
win32: "Windows Credential Manager",
|
|
733
|
+
linux: "the Secret Service keyring"
|
|
734
|
+
};
|
|
735
|
+
/** The keychain store, or `undefined` if this machine doesn't have a working one. */
|
|
736
|
+
const keychainStore = async () => {
|
|
737
|
+
let AsyncEntry;
|
|
738
|
+
try {
|
|
739
|
+
({AsyncEntry} = await import("@napi-rs/keyring"));
|
|
740
|
+
} catch {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const options = { linux: { store: "secret-service" } };
|
|
744
|
+
const entry = (baseURL) => new AsyncEntry(SERVICE, baseURL, options);
|
|
745
|
+
try {
|
|
746
|
+
await entry("probe").getPassword();
|
|
747
|
+
} catch {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
kind: "keychain",
|
|
752
|
+
location: KEYCHAIN_NAMES[process.platform] ?? "the system keychain",
|
|
753
|
+
async get(baseURL) {
|
|
754
|
+
const raw = await entry(baseURL).getPassword();
|
|
755
|
+
if (raw === void 0) return void 0;
|
|
756
|
+
try {
|
|
757
|
+
return parseSaved(JSON.parse(raw), baseURL);
|
|
758
|
+
} catch {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
async set(saved) {
|
|
763
|
+
await entry(saved.baseURL).setPassword(JSON.stringify(record(saved)));
|
|
764
|
+
},
|
|
765
|
+
async delete(baseURL) {
|
|
766
|
+
return entry(baseURL).deletePassword();
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
};
|
|
770
|
+
/** `~/.config/wity/credentials.json`, or under XDG_CONFIG_HOME or APPDATA when set. */
|
|
771
|
+
const credentialsPath = (env) => {
|
|
772
|
+
const base = env.XDG_CONFIG_HOME?.trim() || process.platform === "win32" && env.APPDATA?.trim() || join(env.HOME?.trim() || homedir(), ".config");
|
|
773
|
+
return join(base, "wity", "credentials.json");
|
|
774
|
+
};
|
|
775
|
+
/** POSIX permission checks. Windows files don't carry these modes, so it relies on the profile folder's ACLs. */
|
|
776
|
+
const checkPrivate = async (path, what) => {
|
|
777
|
+
let info;
|
|
778
|
+
try {
|
|
779
|
+
info = await lstat(path);
|
|
780
|
+
} catch (err) {
|
|
781
|
+
if (err.code === "ENOENT") return false;
|
|
782
|
+
throw err;
|
|
783
|
+
}
|
|
784
|
+
if (process.platform === "win32") return true;
|
|
785
|
+
if (info.isSymbolicLink()) throw new CliError(`The credentials ${what} is a symbolic link, so it isn't used: ${path}`, EXIT.auth);
|
|
786
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new CliError(`The credentials ${what} belongs to another user, so it isn't used: ${path}`, EXIT.auth);
|
|
787
|
+
if (what === "file" ? info.mode & 63 : info.mode & 18) {
|
|
788
|
+
const fix = what === "file" ? `chmod 600 "${path}"` : `chmod 700 "${path}"`;
|
|
789
|
+
throw new CliError(`Other users can access the credentials ${what}, so it isn't used: ${path}`, EXIT.auth, `Fix it with: ${fix}`);
|
|
790
|
+
}
|
|
791
|
+
return true;
|
|
792
|
+
};
|
|
793
|
+
const fileStore = (env) => {
|
|
794
|
+
const path = credentialsPath(env);
|
|
795
|
+
const folder = dirname(path);
|
|
796
|
+
const readAll = async () => {
|
|
797
|
+
if (!await checkPrivate(path, "file")) return {};
|
|
798
|
+
try {
|
|
799
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
800
|
+
if (typeof data === "object" && data !== null && !Array.isArray(data)) return data;
|
|
801
|
+
} catch {}
|
|
802
|
+
throw new CliError(`The credentials file is damaged: ${path}`, EXIT.auth, "Delete it and run `wity api-key set` again.");
|
|
803
|
+
};
|
|
804
|
+
const writeAll = async (data) => {
|
|
805
|
+
await mkdir(folder, {
|
|
806
|
+
recursive: true,
|
|
807
|
+
mode: 448
|
|
808
|
+
});
|
|
809
|
+
await checkPrivate(folder, "folder");
|
|
810
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
811
|
+
await writeFile(temp, `${JSON.stringify(data, null, 2)}\n`, {
|
|
812
|
+
mode: 384,
|
|
813
|
+
flag: "wx"
|
|
814
|
+
});
|
|
815
|
+
try {
|
|
816
|
+
await rename(temp, path);
|
|
817
|
+
} catch (err) {
|
|
818
|
+
await rm(temp, { force: true });
|
|
819
|
+
throw err;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
return {
|
|
823
|
+
kind: "file",
|
|
824
|
+
location: path,
|
|
825
|
+
async get(baseURL) {
|
|
826
|
+
return parseSaved((await readAll())[baseURL], baseURL);
|
|
827
|
+
},
|
|
828
|
+
async set(saved) {
|
|
829
|
+
const all = await readAll();
|
|
830
|
+
all[saved.baseURL] = record(saved);
|
|
831
|
+
await writeAll(all);
|
|
832
|
+
},
|
|
833
|
+
async delete(baseURL) {
|
|
834
|
+
const all = await readAll();
|
|
835
|
+
if (!(baseURL in all)) return false;
|
|
836
|
+
delete all[baseURL];
|
|
837
|
+
if (Object.keys(all).length === 0) await rm(path, { force: true });
|
|
838
|
+
else await writeAll(all);
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
};
|
|
843
|
+
const openStore = async (env) => {
|
|
844
|
+
const wanted = (env.WITY_CREDENTIAL_STORE ?? "auto").trim().toLowerCase() || "auto";
|
|
845
|
+
if (![
|
|
846
|
+
"auto",
|
|
847
|
+
"keychain",
|
|
848
|
+
"file"
|
|
849
|
+
].includes(wanted)) throw new CliError("WITY_CREDENTIAL_STORE must be auto, keychain or file.", EXIT.usage);
|
|
850
|
+
if (wanted === "file") return fileStore(env);
|
|
851
|
+
const keychain = await keychainStore();
|
|
852
|
+
if (keychain) return keychain;
|
|
853
|
+
if (wanted === "keychain") throw new CliError("No working keychain was found on this machine.", EXIT.auth, "Unset WITY_CREDENTIAL_STORE to save the key in a private file instead.");
|
|
854
|
+
return fileStore(env);
|
|
855
|
+
};
|
|
856
|
+
/** WITY_API_KEY wins over a saved key, like in the SDK. `wity api-key show` says which one is in use. */
|
|
857
|
+
const findKey = async (baseURL, env, store) => {
|
|
858
|
+
const fromEnv = env.WITY_API_KEY?.trim();
|
|
859
|
+
if (fromEnv) return {
|
|
860
|
+
key: fromEnv,
|
|
861
|
+
source: "env",
|
|
862
|
+
location: "the WITY_API_KEY environment variable"
|
|
863
|
+
};
|
|
864
|
+
const opened = await store();
|
|
865
|
+
const saved = await opened.get(baseURL);
|
|
866
|
+
return saved && {
|
|
867
|
+
key: saved.key,
|
|
868
|
+
source: opened.kind,
|
|
869
|
+
location: opened.location,
|
|
870
|
+
saved
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
/**
|
|
874
|
+
* What's safe to show of a key: its first 9 characters, like the console's key list ("wity_Ab12…").
|
|
875
|
+
* Console keys are 48 characters. A short, unusual key shows only 4, so most of it stays hidden.
|
|
876
|
+
*/
|
|
877
|
+
const keyPrefix = (key) => `${key.slice(0, key.length >= 24 ? 9 : 4)}…`;
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/input.ts
|
|
880
|
+
/** Wity reads at most this many characters of state. From wity.alphanimble.com/docs/limits. */
|
|
881
|
+
const MAX_STATE_CHARS = 32e3;
|
|
882
|
+
/** Stop reading input past this size. Far above what Wity accepts, and small enough to keep in memory. */
|
|
883
|
+
const MAX_READ_BYTES = 4194304;
|
|
884
|
+
const tooBig = () => new CliError(`The input is over ${MAX_READ_BYTES / 1024 / 1024} MB. Wity reads at most ${MAX_STATE_CHARS} characters.`, EXIT.usage);
|
|
885
|
+
/** Everything on stdin, as text. */
|
|
886
|
+
const readStdin = async (stdin) => {
|
|
887
|
+
const chunks = [];
|
|
888
|
+
let size = 0;
|
|
889
|
+
for await (const chunk of stdin) {
|
|
890
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
891
|
+
size += buffer.length;
|
|
892
|
+
if (size > MAX_READ_BYTES) throw tooBig();
|
|
893
|
+
chunks.push(buffer);
|
|
894
|
+
}
|
|
895
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
896
|
+
};
|
|
897
|
+
/** The text to judge: `--text`, `--file` (`-` is stdin), or piped in. Exactly one of them. */
|
|
898
|
+
const readState = async (flags, io) => {
|
|
899
|
+
if (flags.text !== void 0 && flags.file !== void 0) throw new CliError("Give the text with --text or --file, not both.", EXIT.usage);
|
|
900
|
+
let text;
|
|
901
|
+
if (flags.text !== void 0) text = flags.text;
|
|
902
|
+
else if (flags.file !== void 0 && flags.file !== "-") {
|
|
903
|
+
let info;
|
|
904
|
+
try {
|
|
905
|
+
info = await stat(flags.file);
|
|
906
|
+
} catch {
|
|
907
|
+
throw new CliError(`Can't read the file ${flags.file}`, EXIT.usage);
|
|
908
|
+
}
|
|
909
|
+
if (!info.isFile()) throw new CliError(`${flags.file} is not a file.`, EXIT.usage);
|
|
910
|
+
if (info.size > MAX_READ_BYTES) throw tooBig();
|
|
911
|
+
text = await readFile(flags.file, "utf8");
|
|
912
|
+
} else if (flags.file === "-" || !io.stdin.isTTY) text = await readStdin(io.stdin);
|
|
913
|
+
else throw new CliError("No text to judge.", EXIT.usage, "Pass it with --text \"...\", --file path, or pipe it in: cat email.txt | wity noul \"Is this spam?\"");
|
|
914
|
+
text = text.replace(/^/, "");
|
|
915
|
+
if (text.trim() === "") throw new CliError("The text to judge is empty.", EXIT.usage);
|
|
916
|
+
const length = Array.from(text).length;
|
|
917
|
+
if (length > 32e3) throw new CliError(`The text is ${length.toLocaleString("en")} characters. Wity reads at most ${MAX_STATE_CHARS.toLocaleString("en")}.`, EXIT.usage);
|
|
918
|
+
return text;
|
|
919
|
+
};
|
|
920
|
+
/**
|
|
921
|
+
* Ask for a secret on the terminal without showing it. Nothing is echoed, not even dots.
|
|
922
|
+
* Ctrl+C cancels. Escape sequences (arrow keys, bracketed paste markers) are skipped.
|
|
923
|
+
*/
|
|
924
|
+
const readHidden = (io, prompt) => {
|
|
925
|
+
const { stdin } = io;
|
|
926
|
+
if (!stdin.isTTY || !stdin.setRawMode) return Promise.reject(new CliError("There's no terminal to type the key into.", EXIT.usage, "Pipe it in instead: wity api-key set --stdin < key.txt"));
|
|
927
|
+
io.stderr.write(prompt);
|
|
928
|
+
const setRawMode = stdin.setRawMode.bind(stdin);
|
|
929
|
+
setRawMode(true);
|
|
930
|
+
stdin.setEncoding("utf8");
|
|
931
|
+
stdin.resume();
|
|
932
|
+
return new Promise((resolve, reject) => {
|
|
933
|
+
let value = "";
|
|
934
|
+
let inEscape = false;
|
|
935
|
+
const finish = (err) => {
|
|
936
|
+
stdin.off("data", onData);
|
|
937
|
+
setRawMode(false);
|
|
938
|
+
stdin.pause();
|
|
939
|
+
io.stderr.write("\n");
|
|
940
|
+
if (err) reject(err);
|
|
941
|
+
else resolve(value);
|
|
942
|
+
};
|
|
943
|
+
const onData = (chunk) => {
|
|
944
|
+
for (const ch of String(chunk)) {
|
|
945
|
+
if (inEscape) {
|
|
946
|
+
if (/[A-Za-z~]/.test(ch)) inEscape = false;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (ch === "\x1B") inEscape = true;
|
|
950
|
+
else if (ch === "\r" || ch === "\n" || ch === "") return finish();
|
|
951
|
+
else if (ch === "") return finish(new CliError("Cancelled.", EXIT.cancelled));
|
|
952
|
+
else if (ch === "" || ch === "\b") value = Array.from(value).slice(0, -1).join("");
|
|
953
|
+
else if (ch >= " ") value += ch;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
stdin.on("data", onData);
|
|
957
|
+
});
|
|
958
|
+
};
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/schema.ts
|
|
961
|
+
const text = z.string().trim().min(1, "can't be empty");
|
|
962
|
+
const noulSchema = z.strictObject({
|
|
963
|
+
type: z.literal("noul"),
|
|
964
|
+
instructions: text,
|
|
965
|
+
criteria: z.strictObject({
|
|
966
|
+
true: text,
|
|
967
|
+
false: text
|
|
968
|
+
}).optional()
|
|
969
|
+
});
|
|
970
|
+
const choiceSchema = z.strictObject({
|
|
971
|
+
type: z.literal("choice"),
|
|
972
|
+
instructions: text,
|
|
973
|
+
criteria: z.record(z.string().trim().min(1, "option ids can't be empty"), z.string()).refine((options) => Object.keys(options).length >= 2 && Object.keys(options).length <= 256, "needs 2 to 256 options")
|
|
974
|
+
});
|
|
975
|
+
const scoreSchema = z.strictObject({
|
|
976
|
+
type: z.literal("score"),
|
|
977
|
+
instructions: text,
|
|
978
|
+
criteria: z.array(text).min(2, "needs 2 to 10 levels").max(10, "needs 2 to 10 levels")
|
|
979
|
+
});
|
|
980
|
+
const questionSchema = z.discriminatedUnion("type", [
|
|
981
|
+
noulSchema,
|
|
982
|
+
choiceSchema,
|
|
983
|
+
scoreSchema
|
|
984
|
+
]);
|
|
985
|
+
const questionsSchema = z.record(z.string().trim().min(1, "question names can't be empty").max(64), questionSchema).refine((questions) => Object.keys(questions).length > 0, "needs at least one question");
|
|
986
|
+
/** Text, or an object or array that Wity reads as JSON. */
|
|
987
|
+
const stateSchema = z.union([
|
|
988
|
+
text,
|
|
989
|
+
z.record(z.string(), z.json()),
|
|
990
|
+
z.array(z.json())
|
|
991
|
+
]);
|
|
992
|
+
const reasoningSchema = z.enum([
|
|
993
|
+
"off",
|
|
994
|
+
"auto",
|
|
995
|
+
"always"
|
|
996
|
+
]);
|
|
997
|
+
/** A `wity ask -f` file. `state` is optional there: it can come from --text, --file or a pipe instead. */
|
|
998
|
+
const askFileSchema = z.strictObject({
|
|
999
|
+
state: stateSchema.optional(),
|
|
1000
|
+
questions: questionsSchema,
|
|
1001
|
+
reasoning: reasoningSchema.optional(),
|
|
1002
|
+
max_latency_ms: z.number().int().min(200).max(12e4).optional()
|
|
1003
|
+
});
|
|
1004
|
+
/** State length as Wity counts it: characters of the text, or of the JSON for an object. */
|
|
1005
|
+
const stateLength = (state) => typeof state === "string" ? Array.from(state).length : JSON.stringify(state).length;
|
|
1006
|
+
const checkStateLength = (state) => {
|
|
1007
|
+
const length = stateLength(state);
|
|
1008
|
+
if (length > 32e3) throw new CliError(`The state is ${length.toLocaleString("en")} characters. Wity reads at most ${MAX_STATE_CHARS.toLocaleString("en")}.`, EXIT.usage);
|
|
1009
|
+
};
|
|
1010
|
+
/** Zod's issues as one line each: "questions.team.criteria: needs 2 to 256 options". */
|
|
1011
|
+
const describeIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "(file)"}: ${issue.message}`).join("\n ");
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/send.ts
|
|
1014
|
+
const requireKey = async (ctx) => {
|
|
1015
|
+
const found = await findKey(ctx.baseURL, ctx.io.env, ctx.store);
|
|
1016
|
+
if (!found) throw new CliError("No API key found.", EXIT.auth, "Run `wity api-key set`, or set WITY_API_KEY.");
|
|
1017
|
+
return found;
|
|
1018
|
+
};
|
|
1019
|
+
const sendSystemOne = async (ctx, request) => {
|
|
1020
|
+
const found = await requireKey(ctx);
|
|
1021
|
+
const client = makeClient(found.key, ctx.baseURL, ctx.io);
|
|
1022
|
+
const stop = spinner(ctx.io, "Asking Wity…", "Still working. Wity may be thinking this one through…");
|
|
1023
|
+
const started = performance.now();
|
|
1024
|
+
try {
|
|
1025
|
+
return {
|
|
1026
|
+
res: await client.systemOne(request, { signal: ctx.io.signal }),
|
|
1027
|
+
totalMs: performance.now() - started
|
|
1028
|
+
};
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
throw explain(err, found);
|
|
1031
|
+
} finally {
|
|
1032
|
+
stop();
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
const sendGenerate = async (ctx, request) => {
|
|
1036
|
+
const found = await requireKey(ctx);
|
|
1037
|
+
const client = makeClient(found.key, ctx.baseURL, ctx.io);
|
|
1038
|
+
const stop = spinner(ctx.io, "Writing…", "Still writing…");
|
|
1039
|
+
const started = performance.now();
|
|
1040
|
+
try {
|
|
1041
|
+
return {
|
|
1042
|
+
res: await client.generate(request, { signal: ctx.io.signal }),
|
|
1043
|
+
totalMs: performance.now() - started
|
|
1044
|
+
};
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
throw explain(err, found);
|
|
1047
|
+
} finally {
|
|
1048
|
+
stop();
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
//#endregion
|
|
1052
|
+
export { truncate as A, score as B, cost as C, pct as D, painter as E, CliError as F, EXIT as I, DEFAULT_BASE_URL as L, KEYS_PAGE as M, PRICE_PER_MILLION_INPUT_USD as N, shouldColor as O, resolveBaseURL as P, choice as R, cleanBlock as S, ms as T, explain as _, checkStateLength as a, bar as b, MAX_STATE_CHARS as c, readStdin as d, findKey as f, checkKey as g, checkHealth as h, askFileSchema as i, writeJson as j, spinner as k, readHidden as l, openStore as m, sendGenerate as n, describeIssues as o, keyPrefix as p, sendSystemOne as r, questionSchema as s, requireKey as t, readState as u, makeClient as v, decimal as w, clean as x, refusedHint as y, noul as z };
|