@wenathlan/getry 2.0.155 → 2.0.158

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/cli.ts ADDED
@@ -0,0 +1,943 @@
1
+ /**
2
+ * @file cli.ts
3
+ * @module gateway/cli
4
+ * @description
5
+ * The gateway console cli of the ownership wave (wave/ownership.gateway,
6
+ * step 4 — the final double move): moved verbatim from devthink/server.ts
7
+ * (the grand-merge interned gateway console cli), so the gateway owns its
8
+ * console family — one owner, the family consumes, and devthink re-exports
9
+ * the public face for the compat consumers.
10
+ *
11
+ * Responsibilities isolated here (one section, one module):
12
+ * - the console family — the zero-dependency arg parser, the version
13
+ * probe, the ansi colors, the shared stdin question stream, the config
14
+ * search and the scaffold generators (config.mjs, schema.prisma,
15
+ * prismaconfig.ts, .env.example), the interactive version builder, the
16
+ * command dispatch (init add list show validate keys models serve
17
+ * export help), the family entry runclifamily and the console main.
18
+ *
19
+ * Architectural constraints:
20
+ * - Verbatim move: every export name and signature preserved — the
21
+ * devthink server keeps answering the family through one re-export
22
+ * shim, so no consumer edits with the move.
23
+ * - The serve seam (the step 3 report): cmdserve boots THIS owner's
24
+ * transport main (./server.js — the getry transport boot). The devthink
25
+ * server entry keeps its own composed boot for its os lanes and answers
26
+ * serve there itself; the autorun guard below moved with the family and
27
+ * is re-keyed on the cli module, so only a direct `bun cli.ts` (or a
28
+ * built cli.js entry) run executes the console here.
29
+ * - The banner text keeps the historical string the cli test pins
30
+ * ("@wenathlan/devthink gateway"); the house may rename faces in a
31
+ * later wave.
32
+ *
33
+ * Version: 2.1.8 — the ownership wave console cli extraction
34
+ *
35
+ * Related modules:
36
+ * - server.ts — the transport this cli's serve boots (main/runserver)
37
+ * - devthink/server.ts — the compat re-export face of the moved family
38
+ *
39
+ * @author devthink
40
+ * @license MIT
41
+ */
42
+
43
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
44
+ import { basename, join, relative, resolve } from "node:path";
45
+ import { stdin, stdout } from "node:process";
46
+ import { createInterface, type Interface } from "node:readline/promises";
47
+ import { main } from "./server.js";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // arg parsing — zero dependency
51
+ // ---------------------------------------------------------------------------
52
+
53
+ type parsedargs = { flags: Record<string, string | boolean>; positional: string[] };
54
+
55
+ function parseargs(args: string[]): parsedargs {
56
+ const flags: Record<string, string | boolean> = {};
57
+ const positional: string[] = [];
58
+ for (let i = 0; i < args.length; i++) {
59
+ const value = args[i];
60
+ if (!value.startsWith("-")) {
61
+ positional.push(value);
62
+ continue;
63
+ }
64
+ const key = value.replace(/^-+/, "");
65
+ const next = args[i + 1];
66
+ if (next && !next.startsWith("-")) {
67
+ flags[key] = next;
68
+ i += 1;
69
+ } else flags[key] = true;
70
+ }
71
+ return { flags, positional };
72
+ }
73
+
74
+ function stringflag(parsed: parsedargs, key: string): string | undefined {
75
+ const value = parsed.flags[key];
76
+ return typeof value === "string" ? value : undefined;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // version — from package.json or env override
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function version(): string {
84
+ const embedded = process.env.DEVTHINK_VERSION?.trim();
85
+ if (embedded) return embedded;
86
+ // probe both the module directory (source tree) and its parent (the
87
+ // built build/ layout) so the bundled cli.js resolves the version too
88
+ for (const candidate of ["./package.json", "../package.json"]) {
89
+ try {
90
+ const file = new URL(candidate, import.meta.url);
91
+ return String((JSON.parse(readFileSync(file, "utf8")) as { version?: string }).version || "0.0.0");
92
+ } catch {
93
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
94
+ }
95
+ }
96
+ return "0.0.0";
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // console colors — ansi escape codes
101
+ // ---------------------------------------------------------------------------
102
+
103
+ const colors = stdout.isTTY
104
+ ? {
105
+ cyan: "\u001b[36m",
106
+ green: "\u001b[32m",
107
+ yellow: "\u001b[33m",
108
+ red: "\u001b[31m",
109
+ bold: "\u001b[1m",
110
+ dim: "\u001b[2m",
111
+ reset: "\u001b[0m",
112
+ }
113
+ : {
114
+ cyan: "",
115
+ green: "",
116
+ yellow: "",
117
+ red: "",
118
+ bold: "",
119
+ dim: "",
120
+ reset: "",
121
+ };
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // readline — interactive question helper
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** the stdin question stream — ONE readline interface serves every question
128
+ * of a command and buffers lines that arrive while no question is pending
129
+ * (sequential question() calls drop piped input: the 'line' events fire
130
+ * with no listener and the answers are lost). interactive typing resolves
131
+ * the pending question directly; piped answers queue; end-of-input answers
132
+ * every remaining question with its default so piped/automation runs
133
+ * complete. main() closes the interface so the process never hangs. */
134
+ let sharedrl: Interface | undefined;
135
+ const pendinglines: string[] = [];
136
+ let lineresolver: ((line: string) => void) | undefined;
137
+ let stdineof = false;
138
+
139
+ function questionstream(): Interface {
140
+ if (sharedrl) return sharedrl;
141
+ sharedrl = createInterface({ input: stdin, output: stdout });
142
+ sharedrl.on("line", (line: string) => {
143
+ if (lineresolver) {
144
+ const resolve = lineresolver;
145
+ lineresolver = undefined;
146
+ resolve(line);
147
+ } else {
148
+ pendinglines.push(line);
149
+ }
150
+ });
151
+ sharedrl.on("close", () => {
152
+ stdineof = true;
153
+ if (lineresolver) {
154
+ const resolve = lineresolver;
155
+ lineresolver = undefined;
156
+ resolve("");
157
+ }
158
+ });
159
+ return sharedrl;
160
+ }
161
+
162
+ async function ask(question: string, defaultanswer = ""): Promise<string> {
163
+ questionstream();
164
+ const suffix = defaultanswer ? ` (${defaultanswer})` : "";
165
+ stdout.write(`${question}${suffix}: `);
166
+ let answer: string;
167
+ if (pendinglines.length > 0) {
168
+ answer = pendinglines.shift() as string;
169
+ } else if (stdineof) {
170
+ answer = "";
171
+ } else {
172
+ answer = await new Promise<string>((resolve) => {
173
+ lineresolver = resolve;
174
+ });
175
+ }
176
+ return answer.trim() || defaultanswer;
177
+ }
178
+
179
+ async function askyesno(question: string, defaultanswer = false): Promise<boolean> {
180
+ const hint = defaultanswer ? "Y/n" : "y/N";
181
+ const answer = await ask(`${question} [${hint}]`);
182
+ if (!answer) return defaultanswer;
183
+ return answer.toLowerCase().startsWith("y");
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // config file management
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /** the config search names — .mjs first: it is the only flavor immune to
191
+ * the nearest package.json type field (a plain `npm init -y` consumer has
192
+ * no "type": "module", so node reads an esm-syntax .ts or .js as commonjs
193
+ * and the import dies — the .mjs extension is module-typed by the file
194
+ * name itself and loads everywhere) */
195
+ const confignames = [
196
+ "config.mjs",
197
+ "config.ts",
198
+ "config.js",
199
+ "sol/config.mjs",
200
+ "sol/config.ts",
201
+ "sol/config.js",
202
+ "gatewayconfig.mjs",
203
+ "gatewayconfig.ts",
204
+ "gatewayconfig.js",
205
+ ];
206
+
207
+ /** config path — the scaffold target: config.mjs in cwd (the legacy sol/config.mjs location stays a load candidate) */
208
+ function configpath(): string {
209
+ return resolve(process.cwd(), "config.mjs");
210
+ }
211
+
212
+ /** find the config file — returns path plus parsed config or null */
213
+ async function findconfig(): Promise<{
214
+ path: string;
215
+ config: Record<string, unknown>;
216
+ } | null> {
217
+ for (const name of confignames) {
218
+ const path = resolve(process.cwd(), name);
219
+ if (!existsSync(path)) continue;
220
+ try {
221
+ const mod = await import(/* @vite-ignore */ path);
222
+ const config = (mod.default ?? mod.config ?? mod) as Record<string, unknown>;
223
+ if (config && typeof config === "object" && "versions" in config) {
224
+ return { path, config };
225
+ }
226
+ } catch {
227
+ // unreadable config flavor — try the next candidate
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /** read the existing config or return null */
234
+ async function readconfig(): Promise<Record<string, unknown> | null> {
235
+ return (await findconfig())?.config ?? null;
236
+ }
237
+
238
+ /** generate the config file content from a definition object */
239
+ function generateconfigcontent(def: Record<string, unknown>): string {
240
+ return [
241
+ "/**",
242
+ " * web config — user customization layer for the gateway",
243
+ " * generated by the cli — edit freely",
244
+ " * every value here is what you customize",
245
+ " */",
246
+ "",
247
+ `export const config = ${jsontopretty(def)}`,
248
+ "",
249
+ "export default config",
250
+ "",
251
+ ].join("\n");
252
+ }
253
+
254
+ /** json to pretty typescript literal */
255
+ function jsontopretty(value: unknown, indent = 0): string {
256
+ const pad = " ".repeat(indent);
257
+ const padinner = " ".repeat(indent + 1);
258
+ if (value === null || value === undefined) return "null";
259
+ if (typeof value === "string") return JSON.stringify(value);
260
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
261
+ if (Array.isArray(value)) {
262
+ if (value.length === 0) return "[]";
263
+ const items = value.map((v) => `${padinner}${jsontopretty(v, indent + 1)}`);
264
+ return `[\n${items.join(",\n")},\n${pad}]`;
265
+ }
266
+ if (typeof value === "object") {
267
+ const entries = Object.entries(value as Record<string, unknown>);
268
+ if (entries.length === 0) return "{}";
269
+ const lines = entries.map(([k, v]) => `${padinner}${k}: ${jsontopretty(v, indent + 1)}`);
270
+ return `{\n${lines.join(",\n")},\n${pad}}`;
271
+ }
272
+ return JSON.stringify(value);
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // schema template — the prisma schema scaffolded for new users
277
+ // (prisma 7 style: no url in the schema — the datasource url lives in
278
+ // prismaconfig.ts, scaffolded below)
279
+ // ---------------------------------------------------------------------------
280
+
281
+ const schematemplate = `generator client {
282
+ provider = "prisma-client-js"
283
+ }
284
+
285
+ datasource db {
286
+ provider = "sqlite"
287
+ }
288
+
289
+ model ChatMessage {
290
+ id String @id @default(cuid())
291
+ requestid String?
292
+ sessionid String?
293
+ route String?
294
+ provider String?
295
+ version String?
296
+ model String?
297
+ modelvariant String?
298
+ content String?
299
+ reasoningcontent String?
300
+ completion String?
301
+ prompttokens Int?
302
+ completiontokens Int?
303
+ totaltokens Int?
304
+ stream Boolean?
305
+ streammode String?
306
+ endpoint String?
307
+ twocalls Boolean?
308
+ thinkingbudget Int?
309
+ status String?
310
+ httpstatus Int?
311
+ error String?
312
+ rotationindex Int?
313
+ messagenumber Int?
314
+ contextshared Boolean?
315
+ startedat DateTime? @default(now())
316
+ createdat DateTime @default(now())
317
+
318
+ @@index([sessionid])
319
+ @@index([route])
320
+ @@index([model])
321
+ @@index([provider])
322
+ @@index([status])
323
+ @@index([createdat])
324
+ }
325
+
326
+ model ApiKey {
327
+ id String @id @default(cuid())
328
+ keyValue String @unique
329
+ label String?
330
+ active Boolean @default(true)
331
+ status String @default("active")
332
+ useCount Int @default(0)
333
+ errorCount Int @default(0)
334
+ lastUsedAt DateTime?
335
+ lastError String?
336
+ lastErrorAt DateTime?
337
+ createdat DateTime @default(now())
338
+
339
+ @@index([active, status])
340
+ }
341
+
342
+ model NvidiaKey {
343
+ id String @id @default(cuid())
344
+ keyValue String @unique
345
+ label String?
346
+ active Boolean @default(true)
347
+ status String @default("active")
348
+ useCount Int @default(0)
349
+ errorCount Int @default(0)
350
+ lastUsedAt DateTime?
351
+ lastError String?
352
+ lastErrorAt DateTime?
353
+ createdat DateTime @default(now())
354
+
355
+ @@index([active, status])
356
+ }
357
+ `;
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // prisma config template — scaffolded for new users (prisma 7: the
361
+ // datasource url lives here, not in the schema; the default matches the
362
+ // library runtime fallback so db push and serve share one sqlite)
363
+ // ---------------------------------------------------------------------------
364
+
365
+ const prismaconfigtemplate = `/** prisma config — the datasource url lives here (prisma 7 moved it
366
+ * out of the schema file). the schema is schema.prisma at the project
367
+ * root; the url comes from DEVTHINK_DATABASE_URL or DATABASE_URL,
368
+ * defaulting to the local sqlite at devthink.db — the same default the
369
+ * gateway library runtime uses, so the pushed database and the serving
370
+ * process agree on one file with zero configuration */
371
+
372
+ import { existsSync } from "node:fs";
373
+ import path from "node:path";
374
+ import { defineConfig } from "prisma/config";
375
+
376
+ /** resolve schema path — schema.prisma at the root is the standard location */
377
+ function resolveschemapath(): string {
378
+ const candidates = [
379
+ path.resolve(process.cwd(), "schema.prisma"),
380
+ path.resolve(process.cwd(), "Sol", "schema.prisma"),
381
+ path.resolve(process.cwd(), "prisma", "schema.prisma"),
382
+ ];
383
+ for (const candidate of candidates) {
384
+ if (existsSync(candidate)) return candidate;
385
+ }
386
+ return candidates[0];
387
+ }
388
+
389
+ export default defineConfig({
390
+ schema: resolveschemapath(),
391
+ datasource: {
392
+ url:
393
+ process.env.DEVTHINK_DATABASE_URL ||
394
+ process.env.DATABASE_URL ||
395
+ "file:./devthink.db",
396
+ },
397
+ });
398
+ `;
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // init — scaffold the whole project
402
+ // ---------------------------------------------------------------------------
403
+
404
+ async function cmdinit(): Promise<void> {
405
+ console.log(`\n${colors.cyan}${colors.bold}@wenathlan/devthink gateway${colors.reset} v${version()} — init\n`);
406
+
407
+ const targetdir = process.cwd();
408
+
409
+ // check existing config — any flavor counts, the scaffold writes .mjs
410
+ const existing = await findconfig();
411
+ if (existing || existsSync(configpath())) {
412
+ const overwrite = await askyesno(
413
+ `config already exists${existing ? ` (${relative(process.cwd(), existing.path)})` : ""} — overwrite?`,
414
+ false,
415
+ );
416
+ if (!overwrite) {
417
+ console.log("keeping existing config");
418
+ return;
419
+ }
420
+ }
421
+
422
+ // interactive scaffold — how many versions
423
+ console.log("\nthis scaffolds your gateway configuration");
424
+ console.log(
425
+ "each version gets all 7 routes: chat/completions completions messages responses embeddings keys models\n",
426
+ );
427
+
428
+ const versioncountanswer = await ask("how many versions (route groups) do you want", "1");
429
+ const versioncount = Math.max(1, Math.min(20, Number(versioncountanswer) || 1));
430
+
431
+ const versions: Record<string, unknown> = {};
432
+
433
+ for (let i = 0; i < versioncount; i++) {
434
+ console.log(`\n${colors.bold}--- version ${i + 1} of ${versioncount} ---${colors.reset}`);
435
+ const vconfig = await askversiondetails(i + 1);
436
+ versions[vconfig.id as string] = vconfig;
437
+ }
438
+
439
+ const definition = {
440
+ name: await ask("gateway name", "My Gateway"),
441
+ description: await ask("gateway description", "multi provider ai gateway"),
442
+ versions,
443
+ };
444
+
445
+ // write config — .mjs loads under every package.json type (a plain
446
+ // npm init -y consumer has no type module: an esm .ts or .js dies there)
447
+ writeFileSync(configpath(), generateconfigcontent(definition));
448
+ console.log(`\n${colors.green}created${colors.reset} config.mjs`);
449
+
450
+ // write schema
451
+ const schemapath = join(targetdir, "schema.prisma");
452
+ writeFileSync(schemapath, schematemplate);
453
+ console.log(`${colors.green}created${colors.reset} schema.prisma`);
454
+
455
+ // write prisma config — prisma 7 carries the datasource url here (out of
456
+ // the schema file), and the default matches the library runtime fallback
457
+ // so db push and serve agree on one sqlite with zero configuration
458
+ writeFileSync(join(targetdir, "prismaconfig.ts"), prismaconfigtemplate);
459
+ console.log(`${colors.green}created${colors.reset} prismaconfig.ts`);
460
+
461
+ // write env example
462
+ const envpath = join(targetdir, ".env.example");
463
+ const envlines: string[] = [
464
+ "# gateway environment",
465
+ "# database url — libsql http postgres or file",
466
+ "# DEVTHINK_DATABASE_URL=file:./devthink.db",
467
+ "",
468
+ ];
469
+ for (const [id, v] of Object.entries(versions)) {
470
+ const auth = (v as Record<string, unknown>).auth as Record<string, unknown> | undefined;
471
+ if (auth?.envvar) {
472
+ envlines.push(`# ${id} api key`);
473
+ envlines.push(`${auth.envvar}=`);
474
+ envlines.push("");
475
+ }
476
+ }
477
+ writeFileSync(envpath, envlines.join("\n"));
478
+ console.log(`${colors.green}created${colors.reset} .env.example`);
479
+
480
+ console.log(`\n${colors.green}${colors.bold}done${colors.reset} — next steps:`);
481
+ console.log(" 1. edit config.mjs to tune your configuration");
482
+ console.log(" 2. register keys: npx @wenathlan/devthink gateway keys <version>");
483
+ console.log(" 3. install prisma: npm i -D prisma");
484
+ console.log(" 4. push + generate: npx prisma db push --config prismaconfig.ts && npx prisma generate --config prismaconfig.ts");
485
+ console.log(" 5. start the server: npx @wenathlan/devthink gateway serve");
486
+ console.log("");
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // askversiondetails — interactive version builder
491
+ // ---------------------------------------------------------------------------
492
+
493
+ async function askversiondetails(index: number): Promise<Record<string, unknown>> {
494
+ const id = await ask(`version id (path prefix)`, `v${index}`);
495
+ const providername = await ask("provider name (zai nvidia openai openrouter custom)", "custom");
496
+ const baseurl = await ask("upstream base url", "https://gateway.example/v1");
497
+
498
+ // auth
499
+ console.log(
500
+ "\nauth methods: bearer apikeyheader queryparam basic oauth2clientcredentials jwtsign sigv4 anonymous keylesssdk none",
501
+ );
502
+ const authmode = await ask("auth method", "bearer");
503
+ const needskey = ["bearer", "apikeyheader", "queryparam", "basic"].includes(authmode);
504
+ const auth: Record<string, unknown> = { mode: authmode };
505
+ if (needskey) {
506
+ auth.required = await askyesno("is the api key required?", true);
507
+ const envvar = await ask(
508
+ "env var name for the key",
509
+ `${providername.toUpperCase().replace(/[^A-Z]/g, "_")}_API_KEY`,
510
+ );
511
+ auth.envvar = envvar;
512
+ const usedb = await askyesno("store keys in the database (rotation)?", false);
513
+ auth.keysources = usedb ? ["db", "env"] : ["env"];
514
+ if (usedb) auth.dbmodel = await ask("prisma key model name", "apiKey");
515
+ }
516
+
517
+ // models
518
+ const modelcountanswer = await ask("how many models", "2");
519
+ const modelcount = Math.max(1, Number(modelcountanswer) || 2);
520
+ const models: Array<Record<string, unknown>> = [];
521
+ for (let m = 0; m < modelcount; m++) {
522
+ console.log(`\nmodel ${m + 1} of ${modelcount}`);
523
+ const mid = await ask("model id", m === 0 ? "default-model" : `model-${m + 1}`);
524
+ const context = Number(await ask("context window", "131072"));
525
+ const maxoutput = Number(await ask("max output tokens", "32768"));
526
+ models.push({ id: mid, context, maxoutput, free: true });
527
+ }
528
+
529
+ // rotation
530
+ const userotation = await askyesno("\nrotate models (meta model pattern)?", modelcount > 1);
531
+ let rotation: Record<string, unknown> | undefined;
532
+ if (userotation) {
533
+ rotation = {
534
+ mode: await ask("rotation mode (persession perrequest)", "persession"),
535
+ models: models.map((m) => m.id),
536
+ everynmessages: Number(await ask("rotate every n messages", "6")),
537
+ maxmodelrotations: Number(await ask("max model rotations per request", "4")),
538
+ rotateonstatus: [529, 404, 410],
539
+ rotateontimeout: true,
540
+ };
541
+ }
542
+
543
+ // retry
544
+ const useretry = await askyesno("\nenable retry with backoff?", true);
545
+ let retry: Record<string, unknown> | undefined;
546
+ if (useretry) {
547
+ retry = {
548
+ maxretries: Number(await ask("max retries", "3")),
549
+ backoffbasems: Number(await ask("backoff base ms", "400")),
550
+ backoffcapms: Number(await ask("backoff cap ms", "8000")),
551
+ jitter: 0.2,
552
+ statuses: [429, 502, 503, 504],
553
+ nonretryable: [400],
554
+ };
555
+ }
556
+
557
+ // context
558
+ const usecontext = await askyesno("\nshare context across sessions (db history)?", false);
559
+ const context: Record<string, unknown> | undefined = usecontext
560
+ ? {
561
+ restoresessionhistory: true,
562
+ historylimit: 1000,
563
+ truncatemargin: 4096,
564
+ percallfallback: 1048576,
565
+ }
566
+ : undefined;
567
+
568
+ // meta model
569
+ const metaid = await ask("\nmeta model id (the rotation facade)", "devthink");
570
+
571
+ return {
572
+ id,
573
+ providername,
574
+ upstreams: [{ name: providername, baseurl }],
575
+ auth,
576
+ models,
577
+ defaultmodel: rotation ? metaid : models[0].id,
578
+ ...(rotation ? { rotation } : {}),
579
+ ...(retry ? { retry } : {}),
580
+ ...(context ? { context } : {}),
581
+ timeout: { requestms: 300000, streamms: 2147483647 },
582
+ thinking: { defaultlevel: "high" },
583
+ bodybuild: { optionalparamspolicy: "omit-unspecified", clampmaxtokens: true },
584
+ metamodel: {
585
+ id: metaid,
586
+ fallbackcontext: 1048576,
587
+ maxoutput: 32768,
588
+ maskupstreammodel: true,
589
+ alwaysdisplay: true,
590
+ },
591
+ headers: { useragent: `Gateway/${id}` },
592
+ };
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // add — add a new version to the existing config
597
+ // ---------------------------------------------------------------------------
598
+
599
+ async function cmdadd(_versionarg: string | undefined): Promise<void> {
600
+ const existing = await findconfig();
601
+ if (!existing) {
602
+ console.log(`${colors.red}no config found${colors.reset} — run init first`);
603
+ process.exit(1);
604
+ }
605
+ const nextindex = Object.keys((existing.config.versions as Record<string, unknown>) ?? {}).length + 1;
606
+ console.log(`\n${colors.cyan}${colors.bold}add version${colors.reset}\n`);
607
+ const vconfig = await askversiondetails(nextindex);
608
+ const versions = (existing.config.versions as Record<string, unknown>) ?? {};
609
+ versions[vconfig.id as string] = vconfig;
610
+ existing.config.versions = versions;
611
+ writeFileSync(existing.path, generateconfigcontent(existing.config));
612
+ console.log(
613
+ `\n${colors.green}added${colors.reset} version ${vconfig.id} to ${relative(process.cwd(), existing.path)}`,
614
+ );
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // list — show configured versions
619
+ // ---------------------------------------------------------------------------
620
+
621
+ async function cmdlist(): Promise<void> {
622
+ const config = await readconfig();
623
+ if (!config) {
624
+ console.log(`${colors.red}no config found${colors.reset} — run init first`);
625
+ process.exit(1);
626
+ }
627
+ const versions = (config.versions as Record<string, Record<string, unknown>>) ?? {};
628
+ console.log(
629
+ `\n${colors.bold}${config.name ?? "gateway"}${colors.reset} — ${Object.keys(versions).length} versions\n`,
630
+ );
631
+ for (const [id, v] of Object.entries(versions)) {
632
+ const models = (v.models as unknown[])?.length ?? 0;
633
+ const auth = v.auth as Record<string, unknown> | undefined;
634
+ const rotation = v.rotation as Record<string, unknown> | undefined;
635
+ console.log(` ${colors.cyan}${id}${colors.reset}`);
636
+ console.log(
637
+ ` provider: ${v.providername ?? "?"} upstream: ${(v.upstreams as Array<Record<string, unknown>>)?.[0]?.baseurl ?? "?"}`,
638
+ );
639
+ console.log(` models: ${models} auth: ${auth?.mode ?? "?"} rotation: ${rotation?.mode ?? "none"}`);
640
+ }
641
+ console.log("");
642
+ }
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // show — show one version detail
646
+ // ---------------------------------------------------------------------------
647
+
648
+ async function cmdshow(versionarg: string | undefined): Promise<void> {
649
+ if (!versionarg) {
650
+ console.log("usage: gateway show <version>");
651
+ process.exit(1);
652
+ }
653
+ const config = await readconfig();
654
+ const versions = (config?.versions as Record<string, unknown>) ?? {};
655
+ const v = versions[versionarg];
656
+ if (!v) {
657
+ console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
658
+ process.exit(1);
659
+ }
660
+ console.log(jsontopretty(v));
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // validate — check the config
665
+ // ---------------------------------------------------------------------------
666
+
667
+ async function cmdvalidate(): Promise<void> {
668
+ const config = await readconfig();
669
+ if (!config) {
670
+ console.log(`${colors.red}no config found${colors.reset} — run init first`);
671
+ process.exit(1);
672
+ }
673
+ const versions = (config.versions as Record<string, Record<string, unknown>>) ?? {};
674
+ const problems: string[] = [];
675
+ if (Object.keys(versions).length === 0) problems.push("no versions defined");
676
+ for (const [id, v] of Object.entries(versions)) {
677
+ if (!v.providername) problems.push(`version ${id}: missing providername`);
678
+ if (!v.upstreams) problems.push(`version ${id}: missing upstreams`);
679
+ if (!v.auth) problems.push(`version ${id}: missing auth`);
680
+ if (!v.models || (v.models as unknown[]).length === 0) problems.push(`version ${id}: no models`);
681
+ if (!v.metamodel) problems.push(`version ${id}: missing metamodel`);
682
+ }
683
+ if (problems.length === 0) {
684
+ console.log(`${colors.green}config is valid${colors.reset} — ${Object.keys(versions).length} versions`);
685
+ } else {
686
+ console.log(`${colors.yellow}problems found${colors.reset}:`);
687
+ for (const p of problems) console.log(` - ${p}`);
688
+ process.exit(1);
689
+ }
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // keys — register api keys
694
+ // ---------------------------------------------------------------------------
695
+
696
+ async function cmdkeys(versionarg: string | undefined): Promise<void> {
697
+ if (!versionarg) {
698
+ console.log("usage: gateway keys <version>");
699
+ process.exit(1);
700
+ }
701
+ const config = await readconfig();
702
+ const versions = (config?.versions as Record<string, Record<string, unknown>>) ?? {};
703
+ const v = versions[versionarg];
704
+ if (!v) {
705
+ console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
706
+ process.exit(1);
707
+ }
708
+ const auth = v.auth as Record<string, unknown>;
709
+ const envvar = auth?.envvar as string | undefined;
710
+ const dbmodel = auth?.dbmodel as string | undefined;
711
+
712
+ console.log(`\n${colors.bold}register keys for ${versionarg}${colors.reset}`);
713
+ console.log(`auth mode: ${auth?.mode ?? "?"}`);
714
+
715
+ if (envvar) {
716
+ console.log(`\nset keys via environment variable:`);
717
+ console.log(` export ${envvar}=key1,key2,key3 (comma separated for rotation)`);
718
+ }
719
+
720
+ if (dbmodel) {
721
+ console.log(`\nregister keys in the database (rotation with round robin):`);
722
+ const keycountanswer = await ask("how many keys to register now", "0");
723
+ const keycount = Number(keycountanswer) || 0;
724
+ if (keycount > 0) {
725
+ const keys: string[] = [];
726
+ for (let i = 0; i < keycount; i++) {
727
+ const key = await ask(`key ${i + 1}`);
728
+ if (key) keys.push(key);
729
+ }
730
+ // write to a seed script the user can run
731
+ const seedpath = resolve(process.cwd(), "tests", "seedkeys.mjs");
732
+ const seedcontent = [
733
+ "/** seed keys — generated by gateway cli */",
734
+ 'import { PrismaClient } from "@prisma/client"',
735
+ "",
736
+ `const keys = ${JSON.stringify(keys, null, 2)}`,
737
+ "",
738
+ "const db = new PrismaClient()",
739
+ "",
740
+ `for (const [idx, key] of keys.entries()) {`,
741
+ ` await db.${dbmodel}.upsert({`,
742
+ " where: { keyValue: key },",
743
+ ' update: { active: true, status: "active" },',
744
+ ` create: { keyValue: key, label: \`key-\${idx}\`, active: true, status: "active" },`,
745
+ " })",
746
+ "}",
747
+ "",
748
+ "await db.$disconnect()",
749
+ `console.log(\`registered \${keys.length} keys\`)`,
750
+ "",
751
+ ].join("\n");
752
+ mkdirSync(resolve(process.cwd(), "tests"), { recursive: true });
753
+ writeFileSync(seedpath, seedcontent);
754
+ console.log(`\n${colors.green}created${colors.reset} tests/seedkeys.mjs — run: node tests/seedkeys.mjs`);
755
+ }
756
+ }
757
+ console.log("");
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // models — list models for a version
762
+ // ---------------------------------------------------------------------------
763
+
764
+ async function cmdmodels(versionarg: string | undefined): Promise<void> {
765
+ if (!versionarg) {
766
+ console.log("usage: gateway models <version>");
767
+ process.exit(1);
768
+ }
769
+ const config = await readconfig();
770
+ const versions = (config?.versions as Record<string, Record<string, unknown>>) ?? {};
771
+ const v = versions[versionarg];
772
+ if (!v) {
773
+ console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
774
+ process.exit(1);
775
+ }
776
+ const models = (v.models as Array<Record<string, unknown>>) ?? [];
777
+ console.log(`\n${colors.bold}models for ${versionarg}${colors.reset}\n`);
778
+ for (const m of models) {
779
+ console.log(
780
+ ` ${colors.cyan}${m.id}${colors.reset} ctx=${m.context} out=${m.maxoutput}${m.vision ? " vision" : ""}${m.reasoning ? " reasoning" : ""}`,
781
+ );
782
+ }
783
+ const rotation = v.rotation as Record<string, unknown> | undefined;
784
+ if (rotation?.models) {
785
+ console.log(`\n${colors.bold}rotation pool${colors.reset}: ${JSON.stringify(rotation.models)}`);
786
+ }
787
+ console.log("");
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // serve — start the server
792
+ // ---------------------------------------------------------------------------
793
+
794
+ async function cmdserve(portarg: string | undefined): Promise<void> {
795
+ const port = Number(portarg) || Number(process.env.PORT) || 3001;
796
+ process.env.PORT = String(port);
797
+ console.log(`${colors.cyan}starting gateway on port ${port}${colors.reset}`);
798
+ await main();
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // export — export the web scaffold
803
+ // ---------------------------------------------------------------------------
804
+
805
+ async function cmdexport(dirarg: string | undefined): Promise<void> {
806
+ const target = resolve(process.cwd(), dirarg ?? "export");
807
+ mkdirSync(target, { recursive: true });
808
+ const found = await findconfig();
809
+ if (found) {
810
+ const name = basename(found.path);
811
+ writeFileSync(join(target, name), readFileSync(found.path, "utf8"));
812
+ console.log(`${colors.green}exported${colors.reset} ${name}`);
813
+ }
814
+ const schemaroot = resolve(process.cwd(), "schema.prisma");
815
+ const schemapath = existsSync(schemaroot) ? schemaroot : resolve(process.cwd(), "Sol", "schema.prisma");
816
+ if (existsSync(schemapath)) {
817
+ writeFileSync(join(target, "schema.prisma"), readFileSync(schemapath, "utf8"));
818
+ console.log(`${colors.green}exported${colors.reset} schema.prisma`);
819
+ }
820
+ console.log(`\nexported to ${target} — deploy this folder to vercel or netlify`);
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // help
825
+ // ---------------------------------------------------------------------------
826
+
827
+ function printhelp(): void {
828
+ console.log(
829
+ [
830
+ `${colors.cyan}${colors.bold}@wenathlan/devthink gateway${colors.reset} v${version()}`,
831
+ "",
832
+ "the universal ai gateway library — any llm any baseurl any api key",
833
+ "",
834
+ "Usage: devthink gateway <command> [options]",
835
+ "",
836
+ "Commands:",
837
+ " init scaffold config.mjs schema and env interactively",
838
+ " add add a new version to the config",
839
+ " list list configured versions",
840
+ " show <version> show one version config",
841
+ " validate validate the config for errors",
842
+ " keys <version> register api keys for a version",
843
+ " models <version> list models for a version",
844
+ " serve [--port <n>] start the gateway server",
845
+ " export [--dir <path>] export the web folder scaffold",
846
+ " help show this help",
847
+ "",
848
+ "Library usage:",
849
+ ' import { createversion, loadconfig } from "@wenathlan/devthink"',
850
+ "",
851
+ "Documentation: https://github.com/wenathlan/devthink",
852
+ "",
853
+ ].join("\n"),
854
+ );
855
+ }
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // main — dispatch commands
859
+ // ---------------------------------------------------------------------------
860
+
861
+ /**
862
+ * Family entry of the grand merge: the single devthink binary routes the
863
+ * extension command family here (`devthink ext <command>`); the argv is
864
+ * spliced into the process position the original entry reading expects,
865
+ * the family main runs untouched and the caller argv is restored.
866
+ *
867
+ * @param argv the family arguments (without the family prefix).
868
+ */
869
+ export async function runclifamily(argv: string[]): Promise<void> {
870
+ const saved = process.argv.slice();
871
+ process.argv = [saved[0], saved[1], ...argv];
872
+ try {
873
+ await consolemain();
874
+ } finally {
875
+ process.argv = saved;
876
+ }
877
+ }
878
+
879
+ async function consolemain(): Promise<void> {
880
+ const args = process.argv.slice(2);
881
+ const parsed = parseargs(args);
882
+ const command = parsed.positional[0];
883
+
884
+ try {
885
+ switch (command) {
886
+ case "init":
887
+ await cmdinit();
888
+ break;
889
+ case "add":
890
+ await cmdadd(parsed.positional[1]);
891
+ break;
892
+ case "list":
893
+ await cmdlist();
894
+ break;
895
+ case "show":
896
+ await cmdshow(parsed.positional[1]);
897
+ break;
898
+ case "validate":
899
+ await cmdvalidate();
900
+ break;
901
+ case "keys":
902
+ await cmdkeys(parsed.positional[1]);
903
+ break;
904
+ case "models":
905
+ await cmdmodels(parsed.positional[1]);
906
+ break;
907
+ case "serve":
908
+ await cmdserve(stringflag(parsed, "port"));
909
+ break;
910
+ case "export":
911
+ await cmdexport(stringflag(parsed, "dir"));
912
+ break;
913
+ case "help":
914
+ case "--help":
915
+ case "-h":
916
+ case undefined:
917
+ printhelp();
918
+ break;
919
+ default:
920
+ console.log(`${colors.red}unknown command${colors.reset}: ${command}`);
921
+ printhelp();
922
+ process.exit(1);
923
+ }
924
+ } catch (err) {
925
+ console.error(`${colors.red}error${colors.reset}: ${err instanceof Error ? err.message : String(err)}`);
926
+ process.exit(1);
927
+ } finally {
928
+ /** release the shared stdin handle so the process exits cleanly */
929
+ sharedrl?.close();
930
+ }
931
+ }
932
+
933
+ /* the grand-merge guard (the cli move of the gateway ownership wave,
934
+ wave/ownership.gateway step 4): when this module is imported as a library
935
+ surface by the family consumers (the devthink server compat shim, the
936
+ devthink router), the module-load main must not fire — only a direct
937
+ `bun cli.ts` (or a built cli.js entry) run executes the console family,
938
+ and its serve boots the getry transport main the cli moved with (the
939
+ devthink server entry keeps its own composed boot for its os lanes and
940
+ answers serve there itself). */
941
+ const cliDirectEntry = process.argv[1]?.endsWith("cli.ts") || process.argv[1]?.endsWith("cli.js");
942
+ const cliBunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
943
+ if (cliDirectEntry || cliBunEntry) void consolemain();