auto-model-router 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/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { Server } from "bun";
|
|
4
|
+
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
5
|
+
import { createLedger } from "../cost/ledger.ts";
|
|
6
|
+
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
7
|
+
import { createRouter } from "../router/index.ts";
|
|
8
|
+
import { createConversationStore } from "../router/state.ts";
|
|
9
|
+
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
10
|
+
import { UpstreamError } from "../upstream/types.ts";
|
|
11
|
+
import { apiKeySource } from "../config/load.ts";
|
|
12
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
13
|
+
import { createLogger } from "../util/log.ts";
|
|
14
|
+
import { openDb } from "../util/sqlite.ts";
|
|
15
|
+
import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
|
|
16
|
+
import { renderModelList } from "../wire/openai/models.ts";
|
|
17
|
+
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
18
|
+
import { createBufferedSink, createStreamingSink } from "../wire/openai/sink.ts";
|
|
19
|
+
import type { NormRequest, WireError } from "../wire/types.ts";
|
|
20
|
+
import { runTurn } from "./turn.ts";
|
|
21
|
+
|
|
22
|
+
export interface StartedServer {
|
|
23
|
+
// No websocket upgrade path, so the Server payload type is `undefined`.
|
|
24
|
+
server: Server<undefined>;
|
|
25
|
+
stop(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ModelSpendRow {
|
|
29
|
+
slug: string;
|
|
30
|
+
requests: number;
|
|
31
|
+
spendUsd: number;
|
|
32
|
+
/** Fraction of window spend attributable to this model, 0-1. */
|
|
33
|
+
share: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RouterStats {
|
|
37
|
+
generatedAtMs: number;
|
|
38
|
+
/** Window the entry aggregation covers; null ⇒ all retained entries. */
|
|
39
|
+
windowDays: number | null;
|
|
40
|
+
spendTodayUsd: number;
|
|
41
|
+
spend7dUsd: number;
|
|
42
|
+
spendAllTimeUsd: number;
|
|
43
|
+
windowSpendUsd: number;
|
|
44
|
+
requests: number;
|
|
45
|
+
escalations: number;
|
|
46
|
+
escalationRate: number;
|
|
47
|
+
/** Mean |reported - predicted| / predicted over reported entries; null without samples. */
|
|
48
|
+
meanPredictionError: number | null;
|
|
49
|
+
perModel: ModelSpendRow[];
|
|
50
|
+
trust: ModelTrust[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Aggregates the ledger for `/v1/router/stats` and `auto-model-router stats`.
|
|
55
|
+
*
|
|
56
|
+
* The ledger exposes no aggregate queries, so per-model breakdowns are
|
|
57
|
+
* computed over a bounded tail of recent entries; the headline spend numbers
|
|
58
|
+
* use `spendSince`, which is exact.
|
|
59
|
+
*/
|
|
60
|
+
export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs?: number }): RouterStats {
|
|
61
|
+
const nowMs = opts?.nowMs ?? Date.now();
|
|
62
|
+
const windowDays = opts?.windowDays;
|
|
63
|
+
const cutoffMs = windowDays === undefined ? 0 : nowMs - windowDays * 86_400_000;
|
|
64
|
+
|
|
65
|
+
// 100k turns is operational eternity for a single-operator router; the cap
|
|
66
|
+
// only bounds memory on this read, never what the ledger retains.
|
|
67
|
+
const entries = ledger.recentEntries(100_000).filter((e) => e.createdAtMs >= cutoffMs);
|
|
68
|
+
|
|
69
|
+
const dayStart = new Date(nowMs);
|
|
70
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
71
|
+
|
|
72
|
+
let escalations = 0;
|
|
73
|
+
let errorSamples = 0;
|
|
74
|
+
let errorSum = 0;
|
|
75
|
+
let windowSpendUsd = 0;
|
|
76
|
+
const perModel = new Map<string, { requests: number; spendUsd: number }>();
|
|
77
|
+
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
if (e.escalationSignal !== null) escalations += 1;
|
|
80
|
+
if (e.reportedUsd !== null && e.predictedUsd > 0) {
|
|
81
|
+
errorSamples += 1;
|
|
82
|
+
errorSum += Math.abs(e.reportedUsd - e.predictedUsd) / e.predictedUsd;
|
|
83
|
+
}
|
|
84
|
+
// Reported cost is authoritative; predicted stands in while it is missing.
|
|
85
|
+
const spend = e.reportedUsd ?? e.predictedUsd;
|
|
86
|
+
windowSpendUsd += spend;
|
|
87
|
+
const row = perModel.get(e.slug) ?? { requests: 0, spendUsd: 0 };
|
|
88
|
+
row.requests += 1;
|
|
89
|
+
row.spendUsd += spend;
|
|
90
|
+
perModel.set(e.slug, row);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rows: ModelSpendRow[] = [];
|
|
94
|
+
for (const [slug, row] of perModel) {
|
|
95
|
+
rows.push({
|
|
96
|
+
slug,
|
|
97
|
+
requests: row.requests,
|
|
98
|
+
spendUsd: row.spendUsd,
|
|
99
|
+
share: windowSpendUsd > 0 ? row.spendUsd / windowSpendUsd : 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
rows.sort((a, b) => b.spendUsd - a.spendUsd);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
generatedAtMs: nowMs,
|
|
106
|
+
windowDays: windowDays ?? null,
|
|
107
|
+
spendTodayUsd: ledger.spendSince(dayStart.getTime()),
|
|
108
|
+
spend7dUsd: ledger.spendSince(nowMs - 7 * 86_400_000),
|
|
109
|
+
spendAllTimeUsd: ledger.spendSince(0),
|
|
110
|
+
windowSpendUsd,
|
|
111
|
+
requests: entries.length,
|
|
112
|
+
escalations,
|
|
113
|
+
escalationRate: entries.length > 0 ? escalations / entries.length : 0,
|
|
114
|
+
meanPredictionError: errorSamples > 0 ? errorSum / errorSamples : null,
|
|
115
|
+
perModel: rows,
|
|
116
|
+
trust: ledger.allTrust(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function json(data: unknown, status = 200): Response {
|
|
121
|
+
return new Response(JSON.stringify(data), {
|
|
122
|
+
status,
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function wireErrorResponse(err: WireError): Response {
|
|
128
|
+
return new Response(JSON.stringify(renderErrorEnvelope(err)), {
|
|
129
|
+
status: err.status,
|
|
130
|
+
headers: { "content-type": "application/json" },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Maps any failure thrown or rejected inside the turn pipeline to a wire error. */
|
|
135
|
+
function toWireError(err: unknown): WireError {
|
|
136
|
+
if (err instanceof WireErrorException) return err.wireError;
|
|
137
|
+
if (err instanceof UpstreamError) return err.toWireError();
|
|
138
|
+
// Never forward the raw exception text to the client: it can contain
|
|
139
|
+
// filesystem paths, internal URLs, or unexpected exception detail that aids
|
|
140
|
+
// reconnaissance. The caller logs the real message server-side.
|
|
141
|
+
return {
|
|
142
|
+
status: 500,
|
|
143
|
+
code: "internal_error",
|
|
144
|
+
message: "internal error",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when the server is bound to a loopback address (the default). */
|
|
149
|
+
function isLoopbackHost(host: string): boolean {
|
|
150
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "::";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True when a request's Host header names a loopback address. Blunts DNS
|
|
155
|
+
* rebinding: a malicious page that resolves a host to 127.0.0.1 sends a Host
|
|
156
|
+
* header naming its own domain, which this rejects. Only enforced when the
|
|
157
|
+
* server itself is bound to loopback; an operator who explicitly widens the
|
|
158
|
+
* bind to 0.0.0.0 opts out of the check.
|
|
159
|
+
*/
|
|
160
|
+
function isLoopbackHostHeader(hostHeader: string | null): boolean {
|
|
161
|
+
if (hostHeader === null) return false;
|
|
162
|
+
const host = hostHeader.split(":")[0] ?? "";
|
|
163
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function startServer(cfg: RouterConfig): StartedServer {
|
|
167
|
+
const log = createLogger(cfg.logLevel);
|
|
168
|
+
|
|
169
|
+
mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
170
|
+
const db = openDb(cfg.ledger.path);
|
|
171
|
+
const ledger = createLedger(db, cfg);
|
|
172
|
+
const upstream = createOpenRouterClient(cfg);
|
|
173
|
+
const catalog = createCatalog(cfg, upstream, db);
|
|
174
|
+
const conversations = createConversationStore(db);
|
|
175
|
+
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
176
|
+
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog };
|
|
177
|
+
|
|
178
|
+
if (cfg.openrouter.apiKey === "") {
|
|
179
|
+
log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Warm the catalog without blocking listen; the first request may race it,
|
|
183
|
+
// which CatalogSource.get() already serializes.
|
|
184
|
+
catalog.get().catch((err: unknown) => {
|
|
185
|
+
log.warn("initial catalog fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const pruneTimer = setInterval(() => {
|
|
189
|
+
try {
|
|
190
|
+
const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
|
|
191
|
+
if (dropped > 0) log.debug("pruned stale conversations", { dropped });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log.warn("conversation prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
194
|
+
}
|
|
195
|
+
}, 60_000);
|
|
196
|
+
pruneTimer.unref();
|
|
197
|
+
|
|
198
|
+
// Periodically refetch the (key-scoped) catalog in the background so
|
|
199
|
+
// guardrail/preference changes are picked up without needing traffic and a
|
|
200
|
+
// TTL expiry. catalogRefreshMs === 0 disables this.
|
|
201
|
+
let catalogRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
202
|
+
if (cfg.openrouter.catalogRefreshMs > 0) {
|
|
203
|
+
catalogRefreshTimer = setInterval(() => {
|
|
204
|
+
catalog.refresh().catch((err: unknown) => {
|
|
205
|
+
log.warn("periodic catalog refresh failed; keeping last snapshot", {
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}, cfg.openrouter.catalogRefreshMs);
|
|
210
|
+
catalogRefreshTimer.unref();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Cap concurrent in-flight turns so a burst of requests cannot hold many
|
|
214
|
+
// upstream streams at once (each can run up to idleTimeout). Excess requests
|
|
215
|
+
// are rejected with 429 rather than queued, so a local flood cannot pile up
|
|
216
|
+
// unbounded upstream spend or memory.
|
|
217
|
+
const MAX_CONCURRENT_TURNS = 8;
|
|
218
|
+
let inFlightTurns = 0;
|
|
219
|
+
const acquireTurn = (): boolean => {
|
|
220
|
+
if (inFlightTurns >= MAX_CONCURRENT_TURNS) return false;
|
|
221
|
+
inFlightTurns++;
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
const releaseTurn = (): void => {
|
|
225
|
+
inFlightTurns--;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const handleChatCompletions = async (req: Request): Promise<Response> => {
|
|
229
|
+
let normReq: NormRequest;
|
|
230
|
+
try {
|
|
231
|
+
normReq = parseChatRequest(await req.json(), req.headers);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err instanceof WireErrorException) return wireErrorResponse(err.wireError);
|
|
234
|
+
return wireErrorResponse({
|
|
235
|
+
status: 400,
|
|
236
|
+
code: "invalid_json",
|
|
237
|
+
message: err instanceof Error ? err.message : "request body is not valid JSON",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { sink, response } = normReq.stream
|
|
242
|
+
? createStreamingSink(normReq.requestedModel)
|
|
243
|
+
: createBufferedSink(normReq.requestedModel);
|
|
244
|
+
|
|
245
|
+
// The client signal aborts the upstream dispatch on disconnect. runTurn is
|
|
246
|
+
// expected to render its own failures into the sink; this catch is the last
|
|
247
|
+
// line of defence so a rejected turn can never wedge the response.
|
|
248
|
+
runTurn(normReq, sink, turnDeps, req.signal)
|
|
249
|
+
.catch((err: unknown) => {
|
|
250
|
+
log.error("turn failed", { error: err instanceof Error ? err.message : String(err) });
|
|
251
|
+
return Promise.resolve(sink.error(toWireError(err))).catch(() => {});
|
|
252
|
+
})
|
|
253
|
+
.finally(() => {
|
|
254
|
+
// Release the concurrency slot when the turn settles, not when the
|
|
255
|
+
// streaming response object is handed back.
|
|
256
|
+
releaseTurn();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return response;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const server: Server<undefined> = Bun.serve({
|
|
263
|
+
hostname: cfg.server.host,
|
|
264
|
+
port: cfg.server.port,
|
|
265
|
+
// Bun's default idleTimeout is 10s, which is far shorter than a real
|
|
266
|
+
// generation can sit silent: the escalation guard holds the first tokens
|
|
267
|
+
// for up to maxHoldMs while a reasoning model is still producing its
|
|
268
|
+
// first token, and frontier models can think for tens of seconds between
|
|
269
|
+
// chunks. A 10s gap would close the socket mid-stream and surface to omp
|
|
270
|
+
// as "socket connection was closed unexpectedly". idleTimeout is in
|
|
271
|
+
// SECONDS (max 255), so convert from the ms upstream timeout and cap.
|
|
272
|
+
idleTimeout: Math.min(Math.ceil(cfg.openrouter.timeoutMs / 1000), 255),
|
|
273
|
+
async fetch(req: Request): Promise<Response> {
|
|
274
|
+
// Reject requests whose Host header does not name a loopback address
|
|
275
|
+
// when the server is bound to loopback. This blunts DNS rebinding: a
|
|
276
|
+
// malicious page resolving a host to 127.0.0.1 sends its own domain as
|
|
277
|
+
// the Host header, which this rejects. An operator who explicitly
|
|
278
|
+
// widens the bind to 0.0.0.0 opts out of the check.
|
|
279
|
+
if (isLoopbackHost(cfg.server.host) && !isLoopbackHostHeader(req.headers.get("host"))) {
|
|
280
|
+
return wireErrorResponse({ status: 403, code: "forbidden", message: "invalid host" });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (cfg.server.apiKey !== undefined && cfg.server.apiKey !== "") {
|
|
284
|
+
if (req.headers.get("authorization") !== `Bearer ${cfg.server.apiKey}`) {
|
|
285
|
+
return wireErrorResponse({ status: 401, code: "unauthorized", message: "invalid or missing bearer token" });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const url = new URL(req.url);
|
|
290
|
+
try {
|
|
291
|
+
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
292
|
+
if (!acquireTurn()) {
|
|
293
|
+
return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
|
|
294
|
+
}
|
|
295
|
+
return await handleChatCompletions(req);
|
|
296
|
+
}
|
|
297
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
298
|
+
return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
|
299
|
+
}
|
|
300
|
+
if (req.method === "GET" && url.pathname === "/v1/router/stats") {
|
|
301
|
+
return json(computeStats(ledger));
|
|
302
|
+
}
|
|
303
|
+
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
304
|
+
const rawLimit = url.searchParams.get("limit");
|
|
305
|
+
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
306
|
+
const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
|
|
307
|
+
return json({ entries: ledger.recentEntries(limit) });
|
|
308
|
+
}
|
|
309
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
310
|
+
const snap = catalog.peek();
|
|
311
|
+
return json({
|
|
312
|
+
status: "ok",
|
|
313
|
+
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
314
|
+
// Provenance only; never the key itself.
|
|
315
|
+
apiKeySource: apiKeySource(cfg).source,
|
|
316
|
+
catalog: snap === null
|
|
317
|
+
? null
|
|
318
|
+
: {
|
|
319
|
+
models: snap.models.length,
|
|
320
|
+
fetchedAtMs: snap.fetchedAtMs,
|
|
321
|
+
ageMs: Date.now() - snap.fetchedAtMs,
|
|
322
|
+
keyScoped: snap.keyScoped === true,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return wireErrorResponse({ status: 404, code: "not_found", message: `no route for ${req.method} ${url.pathname}` });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
log.error("request failed", { path: url.pathname, error: err instanceof Error ? err.message : String(err) });
|
|
329
|
+
return wireErrorResponse(toWireError(err));
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
server,
|
|
336
|
+
stop: async () => {
|
|
337
|
+
clearInterval(pruneTimer);
|
|
338
|
+
clearInterval(catalogRefreshTimer);
|
|
339
|
+
await server.stop(true);
|
|
340
|
+
db.close();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|