@wenathlan/getry 2.0.164 → 2.0.166
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/accounts.ts +2087 -0
- package/antigravity.ts +2171 -0
- package/auth.ts +265 -0
- package/constants.ts +950 -0
- package/db.ts +810 -0
- package/docs/distributionlanes.md +1 -1
- package/docs/getry.md +33 -0
- package/engine.ts +1535 -0
- package/fingerprint.ts +1461 -0
- package/gateway.ts +1306 -0
- package/gatewayprobe.ts +61 -0
- package/gatewaysmoke.ts +64 -0
- package/intelligence.ts +169 -0
- package/json.ts +932 -0
- package/labels.ts +262 -0
- package/llm.ts +2709 -0
- package/models.ts +4017 -0
- package/nodeshim.ts +265 -0
- package/oauth.ts +2201 -0
- package/package.json +580 -215
- package/project.ts +1293 -0
- package/providers.ts +3238 -0
- package/quota.ts +1891 -0
- package/recovery.ts +2434 -0
- package/request.ts +4496 -0
- package/search.ts +1761 -0
- package/server.ts +766 -0
- package/streaming.ts +4398 -0
- package/thinking.ts +416 -0
- package/types.ts +809 -0
- package/utils.ts +263 -0
- package/validate.ts +61 -0
- package/versionregistry.ts +1325 -0
package/server.ts
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file server.ts
|
|
3
|
+
* @module gateway/server
|
|
4
|
+
* @description
|
|
5
|
+
* The gateway transport of the ownership wave (wave/ownership.gateway):
|
|
6
|
+
* moved verbatim from devthink/server.ts (the grand-merge interned http
|
|
7
|
+
* transport) and devthink/config.ts (the gateway configuration loader),
|
|
8
|
+
* so the gateway owns its transport — one owner, the family consumes, and
|
|
9
|
+
* devthink re-exports the public faces for the compat consumers.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities isolated here (three sections, one module):
|
|
12
|
+
* - sse transport — the keepalive constants, the sse/ndjson header
|
|
13
|
+
* builders, the event writers, the heartbeat discard, the frame
|
|
14
|
+
* parser, the chunk builders, the stream tracking state and
|
|
15
|
+
* makestreamresponse (the byte passthrough with masking and
|
|
16
|
+
* tracking) plus trackwrite
|
|
17
|
+
* - gateway routes — the canonical route kinds (GATEWAYROUTES of
|
|
18
|
+
* gateway.ts), the engine handler map, the gateway half of the
|
|
19
|
+
* merged createserver (cors, the /api/config endpoint, the version
|
|
20
|
+
* loop, the console static roots and the spa fallback), consoleroots
|
|
21
|
+
* and main (the transport boot, exported as runserver too)
|
|
22
|
+
* - gateway config loader — the .mjs-first probe chain over the search
|
|
23
|
+
* bases (loadconfig, reloadconfig, getversion, listversions,
|
|
24
|
+
* validateconfig and the private builtindefault/tryimport/
|
|
25
|
+
* searchbases helpers)
|
|
26
|
+
*
|
|
27
|
+
* Architectural constraints:
|
|
28
|
+
* - Verbatim move: every export name and signature preserved — the
|
|
29
|
+
* devthink server keeps answering the family through one re-export
|
|
30
|
+
* shim, so no consumer edits with the move
|
|
31
|
+
* - The devthink composed createserver still answers the whole surface:
|
|
32
|
+
* the os lanes register on the app this module's createserver built
|
|
33
|
+
* (devthink/server.ts owns the composition, this module owns the
|
|
34
|
+
* transport — no os-lane import ever lands here)
|
|
35
|
+
* - The private createtracker / framedatapayload / trackframe helpers
|
|
36
|
+
* moved with makestreamresponse and stay private in the owner; the
|
|
37
|
+
* private builtindefault / tryimport / searchbases helpers moved with
|
|
38
|
+
* loadconfig the same way
|
|
39
|
+
*
|
|
40
|
+
* Version: 2.1.8 — the ownership wave server family extraction
|
|
41
|
+
*
|
|
42
|
+
* Related modules:
|
|
43
|
+
* - gateway.ts — GATEWAYROUTES (the canonical seven route kinds)
|
|
44
|
+
* - engine.ts — createversion (the version loop mounts its handlers)
|
|
45
|
+
* - types.ts — gatewayconfig, gatewaydefinition (the config surface)
|
|
46
|
+
* - utils.ts — corsheaders, ctsse, ctndjson, the safe json family
|
|
47
|
+
* - devthink/server.ts — the compat re-export face + the composed server
|
|
48
|
+
*
|
|
49
|
+
* @author devthink
|
|
50
|
+
* @license MIT
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
54
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
import { serve } from "@hono/node-server";
|
|
57
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
58
|
+
import { Hono } from "hono";
|
|
59
|
+
import { cors } from "hono/cors";
|
|
60
|
+
import { createversion } from "@wenathlan/getry/engine";
|
|
61
|
+
import type { gatewayconfig, gatewaydefinition } from "@wenathlan/getry/types";
|
|
62
|
+
import { corsheaders, ctndjson, ctsse, safejsonparse, safestringify } from "@wenathlan/getry/utils";
|
|
63
|
+
import { GATEWAYROUTES } from "./gateway.js";
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// sse transport — the stream pipeline of the gateway http transport
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** ka ms — keepalive interval milliseconds 200ms */
|
|
70
|
+
export const kams = 200;
|
|
71
|
+
|
|
72
|
+
/** ka suppress ms — suppress keepalive when data written recently */
|
|
73
|
+
export const kasuppressms = 1000;
|
|
74
|
+
|
|
75
|
+
/** sse headers — headers for sse streaming with x accel buffering no */
|
|
76
|
+
export function sseheaders(): Record<string, string> {
|
|
77
|
+
return {
|
|
78
|
+
...corsheaders,
|
|
79
|
+
"content-type": ctsse,
|
|
80
|
+
"cache-control": "no-cache, no-transform",
|
|
81
|
+
connection: "keep-alive",
|
|
82
|
+
"x-accel-buffering": "no",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** ndjson headers */
|
|
87
|
+
export function ndjsonheaders(): Record<string, string> {
|
|
88
|
+
return {
|
|
89
|
+
...corsheaders,
|
|
90
|
+
"content-type": ctndjson,
|
|
91
|
+
"cache-control": "no-cache, no-transform",
|
|
92
|
+
connection: "keep-alive",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** writeevent — write a single sse event to a controller */
|
|
97
|
+
export function writeevent(
|
|
98
|
+
controller: ReadableStreamDefaultController,
|
|
99
|
+
event: string | null,
|
|
100
|
+
data: unknown,
|
|
101
|
+
format: "sse" | "ndjson" | "json" | "plain" | "octet" = "sse",
|
|
102
|
+
): void {
|
|
103
|
+
const datastr = typeof data === "string" ? data : safestringify(data);
|
|
104
|
+
if (format === "ndjson") {
|
|
105
|
+
controller.enqueue(`${datastr}\n`);
|
|
106
|
+
} else if (format === "plain") {
|
|
107
|
+
controller.enqueue(`${datastr}`);
|
|
108
|
+
} else if (format === "sse") {
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
if (event) parts.push(`event: ${event}`);
|
|
111
|
+
parts.push(`data: ${datastr}`);
|
|
112
|
+
parts.push("");
|
|
113
|
+
parts.push("");
|
|
114
|
+
controller.enqueue(parts.join("\n"));
|
|
115
|
+
} else {
|
|
116
|
+
controller.enqueue(`${datastr}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** writekeepalive — write a keepalive comment invisible to sse and ndjson parsers */
|
|
121
|
+
export function writekeepalive(controller: ReadableStreamDefaultController): void {
|
|
122
|
+
controller.enqueue(": ka\n\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** writedone — write the terminal done marker */
|
|
126
|
+
export function writedone(
|
|
127
|
+
controller: ReadableStreamDefaultController,
|
|
128
|
+
format: "sse" | "ndjson" | "json" | "plain" | "octet" = "sse",
|
|
129
|
+
): void {
|
|
130
|
+
if (format === "sse") {
|
|
131
|
+
controller.enqueue("data: [done]\n\n");
|
|
132
|
+
} else if (format === "ndjson") {
|
|
133
|
+
controller.enqueue('{"done":true}\n');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** safeenqueue — enqueue to controller catching errors */
|
|
138
|
+
export function safeenqueue(controller: ReadableStreamDefaultController, chunk: string): boolean {
|
|
139
|
+
try {
|
|
140
|
+
controller.enqueue(chunk);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** safeclose — close a controller catching errors */
|
|
148
|
+
export function safeclose(controller: ReadableStreamDefaultController): void {
|
|
149
|
+
try {
|
|
150
|
+
controller.close();
|
|
151
|
+
} catch {
|
|
152
|
+
// already closed
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** maskmodel — pure byte regex mask model name to the target id */
|
|
157
|
+
export function maskmodel(chunk: string, target = "devthink"): string {
|
|
158
|
+
const pattern = /"model"\s*:\s*"[^"]*"/g;
|
|
159
|
+
return chunk.replace(pattern, `"model":"${target}"`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** discardheartbeat — check if a line is an sse heartbeat comment
|
|
163
|
+
* the empty-data comparison runs on the trimmed line: "data: " and
|
|
164
|
+
* "data:" both trim to "data:" so the untrimmed spelling never matched */
|
|
165
|
+
export function discardheartbeat(line: string): boolean {
|
|
166
|
+
const trimmed = line.trim();
|
|
167
|
+
return trimmed.startsWith(":") || trimmed === "" || trimmed === "event: ping" || trimmed === "data:";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** parsesseframes — split buffer on double newline returns complete frames plus residual */
|
|
171
|
+
export function parsesseframes(buffer: string): { frames: string[]; residual: string } {
|
|
172
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
173
|
+
const frames: string[] = [];
|
|
174
|
+
let last = 0;
|
|
175
|
+
for (let idx = normalized.indexOf("\n\n", last); idx !== -1; idx = normalized.indexOf("\n\n", last)) {
|
|
176
|
+
frames.push(normalized.slice(last, idx));
|
|
177
|
+
last = idx + 2;
|
|
178
|
+
}
|
|
179
|
+
const residual = normalized.slice(last);
|
|
180
|
+
return { frames, residual };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** extractdata — extract data lines from an sse frame */
|
|
184
|
+
export function extractdata(frame: string): string | null {
|
|
185
|
+
const lines = frame.split("\n");
|
|
186
|
+
const dataLines: string[] = [];
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
if (line.startsWith("data:")) {
|
|
189
|
+
dataLines.push(line.slice(5).trimStart());
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (dataLines.length === 0) return null;
|
|
193
|
+
const joined = dataLines.join("\n");
|
|
194
|
+
// the done marker is case-insensitive: upstreams emit both [DONE]
|
|
195
|
+
// (openai) and [done] (misc gateways)
|
|
196
|
+
return isdone(joined) ? null : joined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** isdone — check if a data string is the done marker
|
|
200
|
+
* case-insensitive: openai emits [DONE] others emit [done] */
|
|
201
|
+
export function isdone(data: string): boolean {
|
|
202
|
+
const trimmed = data.trim().toLowerCase();
|
|
203
|
+
return trimmed === "[done]" || trimmed === "data: [done]" || trimmed === "done";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** makechunk — build a minimal openai compatible chat completion chunk */
|
|
207
|
+
export function makechunk(
|
|
208
|
+
id: string,
|
|
209
|
+
model: string,
|
|
210
|
+
content: string,
|
|
211
|
+
reasoningcontent?: string,
|
|
212
|
+
finishreason?: string | null,
|
|
213
|
+
): Record<string, unknown> {
|
|
214
|
+
const delta: Record<string, unknown> = {};
|
|
215
|
+
if (content) delta.content = content;
|
|
216
|
+
if (reasoningcontent) delta.reasoning_content = reasoningcontent;
|
|
217
|
+
if (!finishreason) delta.role = "assistant";
|
|
218
|
+
return {
|
|
219
|
+
id,
|
|
220
|
+
object: "chat.completion.chunk",
|
|
221
|
+
created: Math.floor(Date.now() / 1000),
|
|
222
|
+
model,
|
|
223
|
+
choices: [
|
|
224
|
+
{
|
|
225
|
+
index: 0,
|
|
226
|
+
delta,
|
|
227
|
+
...(finishreason ? { finish_reason: finishreason } : {}),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** makefinalchunk — build the final chunk with finish reason and optional usage */
|
|
234
|
+
export function makefinalchunk(
|
|
235
|
+
id: string,
|
|
236
|
+
model: string,
|
|
237
|
+
finishreason: string,
|
|
238
|
+
usage?: Record<string, unknown>,
|
|
239
|
+
): Record<string, unknown> {
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
object: "chat.completion.chunk",
|
|
243
|
+
created: Math.floor(Date.now() / 1000),
|
|
244
|
+
model,
|
|
245
|
+
choices: [{ index: 0, delta: {}, finish_reason: finishreason }],
|
|
246
|
+
...(usage ? { usage } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// stream tracking — collects content reasoning usage while forwarding
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/** tracked stream data — collected during passthrough for persistence */
|
|
255
|
+
export interface trackedstream {
|
|
256
|
+
content: string;
|
|
257
|
+
reasoning: string;
|
|
258
|
+
totaltokens: number;
|
|
259
|
+
prompttokens: number;
|
|
260
|
+
completiontokens: number;
|
|
261
|
+
frames: number;
|
|
262
|
+
discarded: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** createtracker — new empty tracking state */
|
|
266
|
+
function createtracker(): trackedstream {
|
|
267
|
+
return {
|
|
268
|
+
content: "",
|
|
269
|
+
reasoning: "",
|
|
270
|
+
totaltokens: 0,
|
|
271
|
+
prompttokens: 0,
|
|
272
|
+
completiontokens: 0,
|
|
273
|
+
frames: 0,
|
|
274
|
+
discarded: 0,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** framedatapayload — the joined data payload of a frame WITHOUT the done
|
|
279
|
+
* nulling (extractdata maps the done marker to null by contract — the
|
|
280
|
+
* stream loop needs the raw payload to recognize the marker and set its
|
|
281
|
+
* terminated flag before that nulling hides it) */
|
|
282
|
+
function framedatapayload(frame: string): string | null {
|
|
283
|
+
const lines = frame.split("\n").filter((l) => l.startsWith("data:"));
|
|
284
|
+
if (lines.length === 0) return null;
|
|
285
|
+
return lines.map((l) => l.slice(5).trim()).join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** trackframe — parse a data frame and accumulate tracked fields */
|
|
289
|
+
function trackframe(tracker: trackedstream, data: string): void {
|
|
290
|
+
const parsed = safejsonparse(data);
|
|
291
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
292
|
+
const obj = parsed as Record<string, unknown>;
|
|
293
|
+
// usage
|
|
294
|
+
const usage = obj["usage"] as Record<string, unknown> | undefined;
|
|
295
|
+
if (usage) {
|
|
296
|
+
if (usage["total_tokens"]) tracker.totaltokens = Number(usage["total_tokens"]);
|
|
297
|
+
if (usage["prompt_tokens"]) tracker.prompttokens = Number(usage["prompt_tokens"]);
|
|
298
|
+
if (usage["completion_tokens"]) tracker.completiontokens = Number(usage["completion_tokens"]);
|
|
299
|
+
}
|
|
300
|
+
// choices deltas
|
|
301
|
+
const choices = obj["choices"] as Array<Record<string, unknown>> | undefined;
|
|
302
|
+
if (choices && choices.length > 0) {
|
|
303
|
+
const delta = choices[0]["delta"] as Record<string, unknown> | undefined;
|
|
304
|
+
if (delta) {
|
|
305
|
+
if (typeof delta["content"] === "string") tracker.content += delta["content"];
|
|
306
|
+
if (typeof delta["reasoning_content"] === "string") tracker.reasoning += delta["reasoning_content"];
|
|
307
|
+
}
|
|
308
|
+
const message = choices[0]["message"] as Record<string, unknown> | undefined;
|
|
309
|
+
if (message) {
|
|
310
|
+
if (typeof message["content"] === "string") tracker.content += message["content"];
|
|
311
|
+
if (typeof message["reasoning_content"] === "string") tracker.reasoning += message["reasoning_content"];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// makestreamresponse — wrap an upstream response into a gateway stream
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** options for makestreamresponse */
|
|
321
|
+
export interface streamoptions {
|
|
322
|
+
/** mask upstream model ids to this id — null disables masking */
|
|
323
|
+
mask?: string | null;
|
|
324
|
+
/** content type override — defaults from upstream response */
|
|
325
|
+
format?: "sse" | "ndjson" | "json" | "plain" | "octet";
|
|
326
|
+
/** keepalive interval ms — default 200 */
|
|
327
|
+
keepaliveintervalms?: number;
|
|
328
|
+
/** suppress keepalive when written within this ms — default 1000 */
|
|
329
|
+
keepalivesuppressms?: number;
|
|
330
|
+
/** callback with tracked data when the stream finishes */
|
|
331
|
+
onfinal?: (tracked: trackedstream) => void;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** makestreamresponse — wrap an upstream fetch response into a gateway streaming response
|
|
335
|
+
* byte passthrough with sse parsing heartbeat discard masking and tracking */
|
|
336
|
+
export function makestreamresponse(upstream: Response, opts: streamoptions): Response {
|
|
337
|
+
const mask = opts.mask ?? null;
|
|
338
|
+
const kai = opts.keepaliveintervalms ?? kams;
|
|
339
|
+
const kasuppress = opts.keepalivesuppressms ?? kasuppressms;
|
|
340
|
+
const tracker = createtracker();
|
|
341
|
+
const upstreamct = upstream.headers.get("content-type") ?? "";
|
|
342
|
+
const format = opts.format ?? (upstreamct.includes("ndjson") ? "ndjson" : "sse");
|
|
343
|
+
|
|
344
|
+
let closed = false;
|
|
345
|
+
let lastwrite = Date.now();
|
|
346
|
+
|
|
347
|
+
const stream = new ReadableStream({
|
|
348
|
+
async start(controller) {
|
|
349
|
+
// every chunk leaves as bytes: a response body is a byte stream —
|
|
350
|
+
// string chunks throw "received non-uint8array chunk" the moment a
|
|
351
|
+
// consumer reads the body (res.text() res.json()) even though node
|
|
352
|
+
// http write happens to tolerate strings — encode uniformly
|
|
353
|
+
const encoder = new TextEncoder();
|
|
354
|
+
const push = (chunk: string | Uint8Array): boolean => {
|
|
355
|
+
try {
|
|
356
|
+
controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
357
|
+
return true;
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const keepalivetimer = setInterval(() => {
|
|
364
|
+
if (closed) return;
|
|
365
|
+
if (Date.now() - lastwrite > kasuppress) {
|
|
366
|
+
push(": ka\n\n");
|
|
367
|
+
}
|
|
368
|
+
}, kai);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const reader = upstream.body?.getReader();
|
|
372
|
+
if (!reader) {
|
|
373
|
+
// a bodyless upstream (new Response(null)) still terminates the
|
|
374
|
+
// downstream stream properly — the early return used to close
|
|
375
|
+
// without the done marker and without the sse terminator
|
|
376
|
+
push("data: [DONE]\n\n");
|
|
377
|
+
closed = true;
|
|
378
|
+
safeclose(controller);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const decoder = new TextDecoder();
|
|
382
|
+
let buffer = "";
|
|
383
|
+
|
|
384
|
+
while (true) {
|
|
385
|
+
const { done, value } = await reader.read();
|
|
386
|
+
if (done) break;
|
|
387
|
+
lastwrite = Date.now();
|
|
388
|
+
|
|
389
|
+
if (mask === null && format !== "sse") {
|
|
390
|
+
// pure byte passthrough — raw bytes forwarded unchanged: no
|
|
391
|
+
// decode encode round trip (multibyte sequences split across
|
|
392
|
+
// chunks stay intact)
|
|
393
|
+
push(value);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// sse mode — parse frames discard heartbeats mask model ids track content
|
|
398
|
+
buffer += decoder.decode(value, { stream: true });
|
|
399
|
+
const { frames, residual } = parsesseframes(buffer);
|
|
400
|
+
buffer = residual;
|
|
401
|
+
for (const frame of frames) {
|
|
402
|
+
if (discardheartbeat(frame)) {
|
|
403
|
+
tracker.discarded += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// the done marker nulls inside extractdata — recognize it from
|
|
407
|
+
// the raw payload first so the terminated flag actually sets
|
|
408
|
+
// (the marker used to fall into the no-data branch, closed never
|
|
409
|
+
// flipped and the loop appended a second done at the end)
|
|
410
|
+
const payload = framedatapayload(frame);
|
|
411
|
+
if (payload !== null && isdone(payload)) {
|
|
412
|
+
push("data: [DONE]\n\n");
|
|
413
|
+
closed = true;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const data = extractdata(frame);
|
|
417
|
+
if (data === null) {
|
|
418
|
+
// frame with no data lines — forward raw
|
|
419
|
+
push(`${frame}\n\n`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// track content for persistence
|
|
423
|
+
tracker.frames += 1;
|
|
424
|
+
trackframe(tracker, data);
|
|
425
|
+
// mask and forward
|
|
426
|
+
if (mask !== null) {
|
|
427
|
+
const masked = data.replace(/"model"\s*:\s*"[^"]*"/g, `"model":"${mask}"`);
|
|
428
|
+
push(`data: ${masked}\n\n`);
|
|
429
|
+
} else {
|
|
430
|
+
push(`data: ${data}\n\n`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// flush residual — through the same done recognition so an
|
|
435
|
+
// unterminated trailing marker terminates instead of duplicating
|
|
436
|
+
if (buffer.trim()) {
|
|
437
|
+
const payload = framedatapayload(buffer);
|
|
438
|
+
if (payload !== null && isdone(payload)) {
|
|
439
|
+
push("data: [DONE]\n\n");
|
|
440
|
+
closed = true;
|
|
441
|
+
} else {
|
|
442
|
+
push(buffer.endsWith("\n\n") ? buffer : `${buffer}\n\n`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// ensure done marker present
|
|
446
|
+
if (!closed) {
|
|
447
|
+
push("data: [DONE]\n\n");
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
// upstream failed mid-stream — emit done and close
|
|
451
|
+
try {
|
|
452
|
+
push("data: [DONE]\n\n");
|
|
453
|
+
} catch {
|
|
454
|
+
/* closed */
|
|
455
|
+
}
|
|
456
|
+
} finally {
|
|
457
|
+
clearInterval(keepalivetimer);
|
|
458
|
+
closed = true;
|
|
459
|
+
safeclose(controller);
|
|
460
|
+
if (opts.onfinal) {
|
|
461
|
+
try {
|
|
462
|
+
opts.onfinal(tracker);
|
|
463
|
+
} catch {
|
|
464
|
+
/* callback error */
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
cancel() {
|
|
470
|
+
closed = true;
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const headers = format === "ndjson" ? ndjsonheaders() : sseheaders();
|
|
475
|
+
return new Response(stream, { headers });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** trackwrite — update last write timestamp for keepalive suppression */
|
|
479
|
+
export function trackwrite(): number {
|
|
480
|
+
return Date.now();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// gateway routes — the version loop mounts the engine handlers per version
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
/** route resources per version — the canonical 7 */
|
|
488
|
+
const resources = GATEWAYROUTES;
|
|
489
|
+
|
|
490
|
+
/** map resource name to its engine handler */
|
|
491
|
+
function handlermap(
|
|
492
|
+
handlers: ReturnType<typeof createversion>,
|
|
493
|
+
resource: string,
|
|
494
|
+
): ((req: Request) => Promise<Response>) | null {
|
|
495
|
+
switch (resource) {
|
|
496
|
+
case "keys":
|
|
497
|
+
return handlers.handlekeys;
|
|
498
|
+
case "models":
|
|
499
|
+
return handlers.handlemodels;
|
|
500
|
+
case "chat/completions":
|
|
501
|
+
return handlers.handlechatcompletions;
|
|
502
|
+
case "completions":
|
|
503
|
+
return handlers.handlecompletions;
|
|
504
|
+
case "messages":
|
|
505
|
+
return handlers.handlemessages;
|
|
506
|
+
case "responses":
|
|
507
|
+
return handlers.handleresponses;
|
|
508
|
+
case "embeddings":
|
|
509
|
+
return handlers.handleembeddings;
|
|
510
|
+
default:
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** createserver — build the hono app from the loaded config (exported at
|
|
516
|
+
* the library surface below, so the battery drives the real routes
|
|
517
|
+
* through app.request without a socket) */
|
|
518
|
+
async function createserver(): Promise<Hono> {
|
|
519
|
+
const app = new Hono();
|
|
520
|
+
|
|
521
|
+
// open cors — all methods all origins
|
|
522
|
+
app.use(
|
|
523
|
+
"*",
|
|
524
|
+
cors({
|
|
525
|
+
origin: "*",
|
|
526
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
527
|
+
allowHeaders: [
|
|
528
|
+
"content-type",
|
|
529
|
+
"authorization",
|
|
530
|
+
"x-token",
|
|
531
|
+
"x-chat-id",
|
|
532
|
+
"x-user-id",
|
|
533
|
+
"x-session-id",
|
|
534
|
+
"x-request-id",
|
|
535
|
+
"x-thinking-level",
|
|
536
|
+
"x-model",
|
|
537
|
+
"x-tools",
|
|
538
|
+
"accept",
|
|
539
|
+
],
|
|
540
|
+
exposeHeaders: ["x-request-id", "x-session-id"],
|
|
541
|
+
maxAge: 86400,
|
|
542
|
+
}),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
// load the user config — config.mjs/config.ts or gatewayconfig.ts
|
|
546
|
+
const definition = await loadconfig();
|
|
547
|
+
const problems = validateconfig(definition);
|
|
548
|
+
|
|
549
|
+
// config endpoint — reports the loaded definition shape
|
|
550
|
+
app.get("/api/config", (c) =>
|
|
551
|
+
c.json({
|
|
552
|
+
name: definition.name ?? "gateway",
|
|
553
|
+
description: definition.description ?? "",
|
|
554
|
+
versions: Object.keys(definition.versions),
|
|
555
|
+
problems,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// register routes for every configured version
|
|
560
|
+
for (const [versionid, versionconfig] of Object.entries(definition.versions)) {
|
|
561
|
+
const handlers = createversion(versionconfig);
|
|
562
|
+
// the chat speaks the OpenAI-compatible contract at the origin root —
|
|
563
|
+
// /v1/* rides the exact handlers the legacy /api/{version} prefix always
|
|
564
|
+
// carried, so the registered gateway base of the chat (same origin or a
|
|
565
|
+
// deployed copy of the family) answers one contract everywhere
|
|
566
|
+
for (const prefix of [`/api/${versionid}`, `/${versionid}`]) {
|
|
567
|
+
// info routes — get returns the version descriptor
|
|
568
|
+
for (const resource of resources) {
|
|
569
|
+
app.get(`${prefix}/${resource}`, (c) => handlers.handleinfo(c.req.raw));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// action routes — post dispatches to the resource handler
|
|
573
|
+
for (const resource of resources) {
|
|
574
|
+
const handler = handlermap(handlers, resource);
|
|
575
|
+
if (handler) {
|
|
576
|
+
app.post(`${prefix}/${resource}`, (c) => handler(c.req.raw));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// static web ui: the host build (cwd/build — a host application or repo dev
|
|
583
|
+
// with its own vite build) wins, then the console that ships inside the
|
|
584
|
+
// package (the bundled http transport lives in the package dist, so a
|
|
585
|
+
// consumer running `npx @wenathlan/gateway serve` with no local build
|
|
586
|
+
// still gets the web console out of the box)
|
|
587
|
+
for (const root of consoleroots()) {
|
|
588
|
+
app.use("*", serveStatic({ root }));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// api 404 fallback
|
|
592
|
+
app.notFound((c) => {
|
|
593
|
+
if (c.req.path.startsWith("/api/")) {
|
|
594
|
+
return c.json({ error: "not found", path: c.req.path }, 404);
|
|
595
|
+
}
|
|
596
|
+
// spa fallback: index.html from the first console root that has it
|
|
597
|
+
for (const root of consoleroots()) {
|
|
598
|
+
const indexpath = join(root, "index.html");
|
|
599
|
+
if (existsSync(indexpath)) {
|
|
600
|
+
return c.html(readFileSync(indexpath, "utf8"));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return c.body(null, 204);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
return app;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** consoleroots — the static roots in priority order: the local build (the
|
|
610
|
+
* host build — cwd relative), then the package's own build when the http
|
|
611
|
+
* transport itself runs from inside it (the bundled build/http.js sits next
|
|
612
|
+
* to the console the package ships; repo-dev runs http.ts from the root, so
|
|
613
|
+
* the module dir only joins the list when it IS a build directory) */
|
|
614
|
+
function consoleroots(): string[] {
|
|
615
|
+
const roots: string[] = ["build"];
|
|
616
|
+
const moduledir = dirname(fileURLToPath(import.meta.url));
|
|
617
|
+
if (basename(moduledir) === "build" && moduledir !== resolve("build")) {
|
|
618
|
+
roots.push(moduledir);
|
|
619
|
+
}
|
|
620
|
+
return roots;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** main — bootstrap the server on the configured port
|
|
624
|
+
* host and port honor env — never hardcoded per open infrastructure rules */
|
|
625
|
+
async function main(): Promise<void> {
|
|
626
|
+
const app = await createserver();
|
|
627
|
+
const definition = await loadconfig();
|
|
628
|
+
const versioncount = Object.keys(definition.versions).length;
|
|
629
|
+
|
|
630
|
+
// global error guards — never crash the process
|
|
631
|
+
process.on("uncaughtException", (err) => {
|
|
632
|
+
console.error("[server] uncaught exception:", err.message);
|
|
633
|
+
});
|
|
634
|
+
process.on("unhandledRejection", (reason) => {
|
|
635
|
+
console.error("[server] unhandled rejection:", reason);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const port = Number(process.env.PORT) || 3001;
|
|
639
|
+
const hostname = process.env.HOST || "0.0.0.0";
|
|
640
|
+
serve({ fetch: app.fetch, port, hostname }, (info: { address: string; port: number }) => {
|
|
641
|
+
console.log(`[server] gateway listening on http://${info.address}:${info.port}`);
|
|
642
|
+
console.log(`[server] config loaded — ${versioncount} versions`);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** export for library usage — users may embed the server */
|
|
647
|
+
export { createserver, main, main as runserver };
|
|
648
|
+
|
|
649
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
650
|
+
Section: the gateway configuration loader (the 1.1.13 embedded-gateway
|
|
651
|
+
definition loader absorbed by the grand merge — the .mjs-first probe
|
|
652
|
+
chain, the version registry and the validator).
|
|
653
|
+
════════════════════════════════════════════════════════════════════ */
|
|
654
|
+
/**
|
|
655
|
+
* configloader — loads the gateway definition from the user customization layer
|
|
656
|
+
* one file one responsibility — only config loading lives here
|
|
657
|
+
*
|
|
658
|
+
* the library is universal and dry — all logic lives at root
|
|
659
|
+
* the user customization lives in config.mjs — the hardcoded v1 v5
|
|
660
|
+
* definitions that ship as the default example
|
|
661
|
+
*
|
|
662
|
+
* resolution order:
|
|
663
|
+
* 1 config.mjs in cwd — the scaffolded standard location
|
|
664
|
+
* 2 gatewayconfig.ts in cwd — user placed config at project root
|
|
665
|
+
* 3 sol/config.mjs — the legacy theme-folder location (still honored)
|
|
666
|
+
* 4 builtin default — the shipped v1 v5 example configs
|
|
667
|
+
*
|
|
668
|
+
* the cli scaffolds a fresh config.mjs for new users
|
|
669
|
+
* users may create as many versions as they want — v1 through v9 and beyond
|
|
670
|
+
*/
|
|
671
|
+
|
|
672
|
+
/** loaded definition cache */
|
|
673
|
+
let loaded: gatewaydefinition | null = null;
|
|
674
|
+
|
|
675
|
+
/** builtin default — empty definition when no user config exists
|
|
676
|
+
* the library is dry — user customization lives in config.mjs
|
|
677
|
+
* the cli scaffolds a fresh config.mjs for new users */
|
|
678
|
+
async function builtindefault(): Promise<gatewaydefinition> {
|
|
679
|
+
return { versions: {} };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** try import from a path — returns null when not found */
|
|
683
|
+
async function tryimport(path: string): Promise<gatewaydefinition | null> {
|
|
684
|
+
try {
|
|
685
|
+
const mod = await import(/* @vite-ignore */ path);
|
|
686
|
+
const candidate = mod.default ?? mod.config ?? mod.gatewaydefinition ?? mod;
|
|
687
|
+
if (candidate && typeof candidate === "object" && "versions" in candidate) {
|
|
688
|
+
return candidate as gatewaydefinition;
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** config search bases — cwd, module dir (source root) and module parent (dist)
|
|
697
|
+
* relative dynamic imports resolve against the importing module url not
|
|
698
|
+
* the process cwd, so the built build/configloader.js must also probe the
|
|
699
|
+
* parent directory where config.mjs lives in a packaged install */
|
|
700
|
+
function searchbases(): string[] {
|
|
701
|
+
const moduledir = dirname(fileURLToPath(import.meta.url));
|
|
702
|
+
const bases = [resolve(process.cwd()), moduledir, resolve(moduledir, "..")];
|
|
703
|
+
return [...new Set(bases)];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** loadconfig — resolve the gateway definition from the standard locations
|
|
707
|
+
* caches the result for the process lifetime — call reloadconfig to refresh */
|
|
708
|
+
export async function loadconfig(): Promise<gatewaydefinition> {
|
|
709
|
+
if (loaded) return loaded;
|
|
710
|
+
// .mjs variants come first in each pair: the extension carries the module
|
|
711
|
+
// type (immune to the nearest package.json type field), so a config
|
|
712
|
+
// scaffolded by the cli loads under plain node in every consumer project
|
|
713
|
+
const names = [
|
|
714
|
+
"config.mjs",
|
|
715
|
+
"config.ts",
|
|
716
|
+
"config.js",
|
|
717
|
+
"sol/config.mjs",
|
|
718
|
+
"sol/config.ts",
|
|
719
|
+
"sol/config.js",
|
|
720
|
+
"gatewayconfig.mjs",
|
|
721
|
+
"gatewayconfig.ts",
|
|
722
|
+
"gatewayconfig.js",
|
|
723
|
+
];
|
|
724
|
+
for (const base of searchbases()) {
|
|
725
|
+
for (const name of names) {
|
|
726
|
+
const found = await tryimport(`${base}/${name}`);
|
|
727
|
+
if (found) {
|
|
728
|
+
loaded = found;
|
|
729
|
+
return found;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// builtin fallback
|
|
734
|
+
loaded = await builtindefault();
|
|
735
|
+
return loaded;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** reloadconfig — clear the cache and reload from disk */
|
|
739
|
+
export async function reloadconfig(): Promise<gatewaydefinition> {
|
|
740
|
+
loaded = null;
|
|
741
|
+
return loadconfig();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** getversion — get one version config by id */
|
|
745
|
+
export async function getversion(id: string): Promise<gatewayconfig | null> {
|
|
746
|
+
const def = await loadconfig();
|
|
747
|
+
return def.versions[id] ?? null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** listversions — get all version ids */
|
|
751
|
+
export async function listversions(): Promise<string[]> {
|
|
752
|
+
const def = await loadconfig();
|
|
753
|
+
return Object.keys(def.versions);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/* validateconfig moved to the pure owner (validate.ts — the ownership
|
|
757
|
+
* correction of the gateway step 3 fold): the checks never needed the
|
|
758
|
+
* transport, and the import plus re-export below keeps this server face
|
|
759
|
+
* answering the same symbol (the createserver lane reads it at line ~547,
|
|
760
|
+
* every external consumer resolves the export) while the devthink config
|
|
761
|
+
* bridge resolves the pure module directly — the browser graph never
|
|
762
|
+
* loads the hono stack the validator used to drag along. The contract is
|
|
763
|
+
* verbatim: one definition in, one problem list out, synchronous always. */
|
|
764
|
+
import { validateconfig } from "./validate.js";
|
|
765
|
+
export { validateconfig };
|
|
766
|
+
|