myapikey 0.12.0 → 0.14.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/README.md CHANGED
@@ -158,6 +158,22 @@ Quick smoke test without any tool:
158
158
  myapikey call gpt-4o-mini "Say hello in one sentence."
159
159
  ```
160
160
 
161
+ **Why two base URLs?** The gateway exposes two separate agent surfaces — `/openai/v1` and `/anthropic/v1` — each with its own `GET /models` (an OpenAI client discovers only openai-enabled models, an Anthropic client only anthropic-enabled ones). Each ecosystem's SDK appends its own paths, so the OpenAI SDK points at `…/openai/v1` (it appends `/chat/completions`, `/responses`, `/models`) and the Anthropic SDK / Claude Code points at `…/anthropic` (it appends `/v1/messages`, `/v1/models`).
162
+
163
+ **Raw HTTP** (no SDK) — hit either surface directly with the gateway API key as a `Bearer` token:
164
+
165
+ ```bash
166
+ # OpenAI family
167
+ curl http://localhost:7800/openai/v1/chat/completions \
168
+ -H "Authorization: Bearer <gateway api key>" -H "Content-Type: application/json" \
169
+ -d '{"model":"<model>","messages":[{"role":"user","content":"hi"}]}'
170
+
171
+ # Anthropic family
172
+ curl http://localhost:7800/anthropic/v1/messages \
173
+ -H "Authorization: Bearer <gateway api key>" -H "Content-Type: application/json" \
174
+ -d '{"model":"<model>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
175
+ ```
176
+
161
177
  Whichever endpoint you point a tool at, the gateway forwards in that format and never translates — so make sure each model you call is backed by at least one source on the matching slot ([see below](#how-routing-works)).
162
178
 
163
179
  ---
@@ -239,9 +255,9 @@ It's the same admin API the CLI uses — configure either way.
239
255
 
240
256
  - `POST /openai/v1/chat/completions` — OpenAI-format proxy
241
257
  - `POST /openai/v1/responses` — OpenAI Responses API (only sources marked `supportsResponses`)
242
- - `GET /openai/v1/models` — models enabled on the **openai** slot, OpenAI list shape
258
+ - `GET /openai/v1/models` — models enabled on the **openai** slot, OpenAI list shape (public — no key needed)
243
259
  - `POST /anthropic/v1/messages` — Anthropic-format proxy
244
- - `GET /anthropic/v1/models` — models enabled on the **anthropic** slot, OpenAI list shape
260
+ - `GET /anthropic/v1/models` — models enabled on the **anthropic** slot, Anthropic list shape (public — no key needed)
245
261
  - `GET /health` — public liveness check
246
262
 
247
263
  **Admin (`/admin`, account password — HTTP Basic):**
package/README.zh-CN.md CHANGED
@@ -158,6 +158,22 @@ export ANTHROPIC_API_KEY=<网关 API Key>
158
158
  myapikey call gpt-4o-mini "用一句话打个招呼。"
159
159
  ```
160
160
 
161
+ **为什么有两个 Base URL?** 网关暴露了两个互相独立的 agent 面——`/openai/v1` 和 `/anthropic/v1`,各自带自己的 `GET /models`(OpenAI 客户端只发现 openai 槽位启用的模型,Anthropic 客户端只发现 anthropic 槽位启用的模型)。两套生态的 SDK 各自拼接自己的路径:OpenAI SDK 指向 `…/openai/v1`(它自己补 `/chat/completions`、`/responses`、`/models`),Anthropic SDK / Claude Code 指向 `…/anthropic`(它自己补 `/v1/messages`、`/v1/models`)。
162
+
163
+ **直接用 HTTP**(不走 SDK)——带上网关 API Key(`Bearer`)直接打这两个面之一:
164
+
165
+ ```bash
166
+ # OpenAI 系
167
+ curl http://localhost:7800/openai/v1/chat/completions \
168
+ -H "Authorization: Bearer <网关 API Key>" -H "Content-Type: application/json" \
169
+ -d '{"model":"<模型名>","messages":[{"role":"user","content":"hi"}]}'
170
+
171
+ # Anthropic 系
172
+ curl http://localhost:7800/anthropic/v1/messages \
173
+ -H "Authorization: Bearer <网关 API Key>" -H "Content-Type: application/json" \
174
+ -d '{"model":"<模型名>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
175
+ ```
176
+
161
177
  不管你把工具指向哪个端点,网关都按那个格式转发、绝不翻译——所以确保你调用的每个模型,在对应的槽位上至少有一个来源在服务它([见下文](#路由是怎么工作的))。
162
178
 
163
179
  ---
@@ -239,9 +255,9 @@ myapikey call gpt-4o-mini "用一句话打个招呼。"
239
255
 
240
256
  - `POST /openai/v1/chat/completions` — OpenAI 格式代理
241
257
  - `POST /openai/v1/responses` — OpenAI Responses API(仅限标记了 `supportsResponses` 的来源)
242
- - `GET /openai/v1/models` — 启用在 **openai** 槽位的模型,OpenAI 列表格式
258
+ - `GET /openai/v1/models` — 启用在 **openai** 槽位的模型,OpenAI 列表格式(公开——无需 Key)
243
259
  - `POST /anthropic/v1/messages` — Anthropic 格式代理
244
- - `GET /anthropic/v1/models` — 启用在 **anthropic** 槽位的模型,OpenAI 列表格式
260
+ - `GET /anthropic/v1/models` — 启用在 **anthropic** 槽位的模型,Anthropic 列表格式(公开——无需 Key)
245
261
  - `GET /health` — 公开存活检查
246
262
 
247
263
  **管理(`/admin`,账号密码 —— HTTP Basic):**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Personal LLM API gateway & proxy — one address + one API key for all your models. Forwards OpenAI & Anthropic calls to your backends with failover and a circuit breaker. Pure passthrough, no format translation. Self-hosted (CLI + web UI).",
6
6
  "keywords": [
@@ -54,7 +54,9 @@ program
54
54
  console.log(` proxy: ${url}/openai/v1/chat/completions (OpenAI)`);
55
55
  console.log(` ${url}/openai/v1/responses (OpenAI Responses)`);
56
56
  console.log(` ${url}/anthropic/v1/messages (Anthropic)`);
57
- console.log(` data: ${dataDir} (override with --data-dir or MYAPIKEY_DATA_DIR)\n`);
57
+ console.log(` data: ${dataDir} (override with --data-dir or MYAPIKEY_DATA_DIR)`);
58
+ console.log(` log: ${store.getPaths().serverLogFile} (errors + failover/cooldown events; level via MYAPIKEY_LOG_LEVEL)\n`);
59
+ store.getLogger().info(`gateway started on port ${info.port}, data=${dataDir}`);
58
60
 
59
61
  if (firstRun) {
60
62
  const { account, apiKey } = store.get();
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type Context } from "hono";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { existsSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
@@ -17,13 +17,21 @@ export function createApp(store: Store, opts: AppOptions = {}): Hono {
17
17
 
18
18
  app.get("/health", (c) => c.json({ ok: true, version: "0.1.0" }));
19
19
 
20
+ // Runtime log (console + server.log): auth failures, unhandled errors.
21
+ const logger = store.getLogger();
22
+ app.onError((err, c) => {
23
+ logger.error(`unhandled error on ${c.req.method} ${c.req.path}: ${err.stack ?? String(err)}`);
24
+ return c.json({ error: { message: "internal error", type: "server_error" } }, 500);
25
+ });
26
+
20
27
  // Two independent secrets: the account password admins /admin (Basic), the
21
28
  // API key gates /v1 (Bearer / x-api-key). Neither works on the other's surface.
22
29
  const accountAuth = authMiddleware(
23
30
  () => store.get().account.username,
24
31
  () => store.get().account.password,
32
+ logger,
25
33
  );
26
- const apiKeyAuth = apiKeyMiddleware(() => store.get().apiKey);
34
+ const apiKeyAuth = apiKeyMiddleware(() => store.get().apiKey, logger);
27
35
 
28
36
  // Both sub-apps require auth, applied inside each sub-app (before routes).
29
37
  // Two agent surfaces, each with its own /models: /openai/v1 (openai family)
@@ -35,6 +43,20 @@ export function createApp(store: Store, opts: AppOptions = {}): Hono {
35
43
  app.route("/anthropic/v1", anthropic);
36
44
  app.route("/admin", admin);
37
45
 
46
+ // Unmatched paths under an API prefix answer JSON 404 — NOT the SPA's
47
+ // index.html. A misconfigured client (e.g. a doubled /v1 segment) then gets a
48
+ // parseable error instead of HTML that only blows up later in its JSON parser
49
+ // as "Unexpected token '<'" (this bit pi's model refresh once).
50
+ const apiMiss = (c: Context) =>
51
+ c.json({ error: { message: `no such endpoint: ${c.req.method} ${c.req.path}`, type: "invalid_request_error" } }, 404);
52
+ app.all("/openai/*", apiMiss);
53
+ app.all("/anthropic/*", apiMiss);
54
+ app.all("/admin/*", apiMiss);
55
+ // Legacy pre-0.12 surface: gone since the split — point the caller at the two
56
+ // current surfaces instead of a bare 404.
57
+ app.all("/v1/*", (c) =>
58
+ c.json({ error: { message: "the /v1 surface was split in v0.12.0 — use /openai/v1 or /anthropic/v1", type: "invalid_request_error" } }, 404));
59
+
38
60
  // Web UI: serve built SPA when available.
39
61
  if (opts.webDir && existsSync(opts.webDir)) {
40
62
  app.use("/*", serveStatic({ root: opts.webDir }));
@@ -1,5 +1,6 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { timingSafeEqual } from "node:crypto";
3
+ import type { Logger } from "./logger";
3
4
 
4
5
  /** Constant-time string compare. */
5
6
  function safeEqual(a: string, b: string): boolean {
@@ -35,7 +36,7 @@ export function extractSecret(c: Context): { password: string; username?: string
35
36
  }
36
37
 
37
38
  /** Hono middleware: require the single account/password. */
38
- export function authMiddleware(getUser: () => string, getPass: () => string): MiddlewareHandler {
39
+ export function authMiddleware(getUser: () => string, getPass: () => string, logger?: Logger): MiddlewareHandler {
39
40
  return async (c, next) => {
40
41
  const cred = extractSecret(c);
41
42
  const ok =
@@ -43,6 +44,7 @@ export function authMiddleware(getUser: () => string, getPass: () => string): Mi
43
44
  (cred.username === undefined || safeEqual(cred.username, getUser())) &&
44
45
  safeEqual(cred.password, getPass());
45
46
  if (!ok) {
47
+ logger?.warn(`auth failed: admin ${c.req.method} ${c.req.path} (invalid or missing credentials)`);
46
48
  return c.json(
47
49
  { error: { message: "invalid or missing credentials", type: "authentication_error" } },
48
50
  401,
@@ -58,12 +60,13 @@ export function authMiddleware(getUser: () => string, getPass: () => string): Mi
58
60
  * secret. extractSecret sets `username` only for Basic, so rejecting when it's
59
61
  * present keeps the account password off /v1.
60
62
  */
61
- export function apiKeyMiddleware(getKey: () => string): MiddlewareHandler {
63
+ export function apiKeyMiddleware(getKey: () => string, logger?: Logger): MiddlewareHandler {
62
64
  return async (c, next) => {
63
65
  const cred = extractSecret(c);
64
66
  const ok =
65
67
  !!cred && cred.username === undefined && !!cred.password && safeEqual(cred.password, getKey());
66
68
  if (!ok) {
69
+ logger?.warn(`auth failed: agent ${c.req.method} ${c.req.path} (invalid or missing api key)`);
67
70
  return c.json(
68
71
  { error: { message: "invalid or missing api key", type: "authentication_error" } },
69
72
  401,
@@ -0,0 +1,72 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /** Runtime-log levels, ascending severity. `silent` emits nothing. */
5
+ export type LogLevel = "info" | "warn" | "error" | "silent";
6
+
7
+ const ORDER: Record<LogLevel, number> = { info: 0, warn: 1, error: 2, silent: 3 };
8
+
9
+ export interface Logger {
10
+ info(msg: string): void;
11
+ warn(msg: string): void;
12
+ error(msg: string): void;
13
+ }
14
+
15
+ export interface LoggerOptions {
16
+ /** Log file to append to (parent dirs created). Omit = console only. */
17
+ file?: string;
18
+ /** Minimum emitted level. Defaults to the MYAPIKEY_LOG_LEVEL env, else "info". */
19
+ level?: LogLevel;
20
+ /** Mirror to stdout (info/warn) / stderr (error). Default true. */
21
+ console?: boolean;
22
+ /** Size cap before the file rotates to `<file>.1`. Default 5 MiB. */
23
+ rotateBytes?: number;
24
+ }
25
+
26
+ /** Parse MYAPIKEY_LOG_LEVEL; anything unrecognized falls back to "info". */
27
+ export function levelFromEnv(v: string | undefined): LogLevel {
28
+ return v === "info" || v === "warn" || v === "error" || v === "silent" ? v : "info";
29
+ }
30
+
31
+ const DEFAULT_ROTATE_BYTES = 5 * 1024 * 1024;
32
+
33
+ /** Process-level runtime log — the ops-facing line the gateway was missing:
34
+ * one plain text line per error or notable event (failover, circuit cooldown,
35
+ * auth failure, unhandled exception), mirrored to the console and appended to
36
+ * `<dataDir>/server.log`. Deliberately distinct from the per-call history in
37
+ * logs.jsonl (that's the web Logs/Stats surface; this is "what happened to
38
+ * the process"). Successful requests are NOT logged here — keep it quiet.
39
+ * File I/O is best-effort: logging must never throw into a request path. */
40
+ export function createLogger(opts: LoggerOptions = {}): Logger {
41
+ const level = opts.level ?? levelFromEnv(process.env.MYAPIKEY_LOG_LEVEL);
42
+ const useConsole = opts.console ?? true;
43
+ const cap = opts.rotateBytes ?? DEFAULT_ROTATE_BYTES;
44
+
45
+ const write = (lvl: "info" | "warn" | "error", msg: string) => {
46
+ if (ORDER[lvl] < ORDER[level]) return;
47
+ const line = `${new Date().toISOString()} ${lvl.toUpperCase().padEnd(5)} ${msg}`;
48
+ if (useConsole) {
49
+ // error → stderr so a log collector splits real failures from chatter
50
+ (lvl === "error" ? process.stderr : process.stdout).write(line + "\n");
51
+ }
52
+ if (opts.file) {
53
+ try {
54
+ mkdirSync(dirname(opts.file), { recursive: true });
55
+ // Keep-one rotation: past the cap, the current file becomes `.1`
56
+ // (overwriting any older backup) and a fresh file starts.
57
+ if (existsSync(opts.file) && statSync(opts.file).size > cap) {
58
+ renameSync(opts.file, `${opts.file}.1`);
59
+ }
60
+ appendFileSync(opts.file, line + "\n");
61
+ } catch {
62
+ // Disk full / permissions — swallow; never fail a request over logging.
63
+ }
64
+ }
65
+ };
66
+
67
+ return {
68
+ info: (m) => write("info", m),
69
+ warn: (m) => write("warn", m),
70
+ error: (m) => write("error", m),
71
+ };
72
+ }
@@ -293,21 +293,27 @@ function observedBody(
293
293
  });
294
294
  }
295
295
 
296
- /** OpenAI-style model list of the models enabled on ONE routing family's slot.
297
- * Each agent surface gets its own `/models` so a client listing models never
298
- * picks an id that 404s on that surface's call endpoint: `/openai/v1/models`
299
- * advertises the openai slot, `/anthropic/v1/models` the anthropic slot. */
296
+ /** Model list of the models enabled on ONE routing family's slot. Each agent
297
+ * surface gets its own `/models` so a client listing models never picks an id
298
+ * that 404s on that surface's call endpoint — and each answers in its own
299
+ * ecosystem's list shape: openai `{object:"list", data:[{id, owned_by}]}` vs
300
+ * anthropic `{data:[{id, display_name}], first_id, last_id, has_more}`. We
301
+ * can't know real created_at / capabilities, so the anthropic shape carries
302
+ * only the honest minimal fields rather than fabricating them. */
300
303
  function modelsList(c: Context, store: Store, fmt: "openai" | "anthropic") {
301
304
  const d = store.get();
302
305
  const byId = new Map(d.providers.map((p) => [p.id, p]));
303
- const data = Object.entries(d.models)
304
- .filter(([, e]) => e[fmt].enabled)
305
- .map(([id, e]) => ({
306
- id,
307
- object: "model",
308
- created: 0,
309
- owned_by: byId.get(e[fmt].providers[0]?.id ?? "")?.name || "MyAPIKey",
310
- }));
306
+ const enabled = Object.entries(d.models).filter(([, e]) => e[fmt].enabled);
307
+ if (fmt === "anthropic") {
308
+ const data = enabled.map(([id]) => ({ id, display_name: id, created_at: "1970-01-01T00:00:00Z", type: "model" }));
309
+ return c.json({ data, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, has_more: false });
310
+ }
311
+ const data = enabled.map(([id, e]) => ({
312
+ id,
313
+ object: "model",
314
+ created: 0,
315
+ owned_by: byId.get(e.openai.providers[0]?.id ?? "")?.name || "MyAPIKey",
316
+ }));
311
317
  return c.json({ object: "list", data });
312
318
  }
313
319
 
@@ -320,6 +326,13 @@ export function proxyApi(
320
326
  ): { openai: Hono; anthropic: Hono } {
321
327
  const openai = new Hono();
322
328
  const anthropic = new Hono();
329
+ // GET /models is a PUBLIC discovery read — no api key required. It returns only
330
+ // the enabled model names (like /health), so an agent or a quick curl can see
331
+ // what each surface offers before wiring up auth. Registered BEFORE the auth
332
+ // middleware so it isn't gated: Hono only runs middleware on routes registered
333
+ // after it.
334
+ openai.get("/models", (c) => modelsList(c, store, "openai"));
335
+ anthropic.get("/models", (c) => modelsList(c, store, "anthropic"));
323
336
  openai.use("*", auth);
324
337
  anthropic.use("*", auth);
325
338
 
@@ -373,6 +386,16 @@ export function proxyApi(
373
386
  const start = Date.now();
374
387
  let lastStatus = 502;
375
388
  let lastErr = "";
389
+ // Runtime log (console + server.log). Errors and notable events only — the
390
+ // per-call history these lines summarize goes to pushLog/logs.jsonl anyway.
391
+ // UI-triggered probes are excluded: their outcome is shown inline already.
392
+ const rt = store.getLogger();
393
+ const sayFailover = (p: Provider, why: string) => {
394
+ if (!isProbe) rt.warn(`proxy model=${model}: provider '${p.name}' ${why} → trying next`);
395
+ };
396
+ const sayCooldown = (p: Provider, r: { entered: boolean; fails: number; cooldownMs: number }) => {
397
+ if (r.entered && !isProbe) rt.warn(`proxy circuit open: provider '${p.name}' cooldown=${r.cooldownMs}ms fails=${r.fails}`);
398
+ };
376
399
 
377
400
  // Skip providers that are either in circuit-breaker cooldown OR over their
378
401
  // RPM pacing cap. Both are heuristics: if every candidate is skipped, fall
@@ -414,9 +437,11 @@ export function proxyApi(
414
437
  lastStatus = 502;
415
438
  lastErr = "network error";
416
439
  if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
440
+ sayFailover(provider, "network error");
417
441
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
418
442
  if (r.entered) {
419
443
  store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
444
+ sayCooldown(provider, r);
420
445
  }
421
446
  continue;
422
447
  }
@@ -446,6 +471,7 @@ export function proxyApi(
446
471
  // A pinned per-source probe takes no circuit side-effects (a manual
447
472
  // test must not trip the breaker) — mirrors the retryable branch.
448
473
  if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
474
+ if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
449
475
  store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
450
476
  }
451
477
  },
@@ -459,6 +485,7 @@ export function proxyApi(
459
485
  const txt = await upstream.text().catch(() => "");
460
486
  lastErr = shortError(txt) || `HTTP ${upstream.status}`;
461
487
  if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
488
+ sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
462
489
  // A 429/overloaded upstream usually carries Retry-After; honoring it
463
490
  // cools for exactly as long as asked (clamped) instead of the escalating
464
491
  // guess. Absent (5xx often, OR a quota error that buried the reset time
@@ -469,6 +496,7 @@ export function proxyApi(
469
496
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
470
497
  if (r.entered) {
471
498
  store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
499
+ sayCooldown(provider, r);
472
500
  }
473
501
  continue;
474
502
  }
@@ -481,6 +509,7 @@ export function proxyApi(
481
509
 
482
510
  const last = order[order.length - 1];
483
511
  const lastUpstreamModel = last.model && last.model !== model ? last.model : undefined;
512
+ if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
484
513
  store.pushLog({ ts: Date.now(), model, upstreamModel: lastUpstreamModel, provider: last.provider.name, providerId: last.provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
485
514
  // A pinned (per-source) probe failed: surface the REAL upstream status the
486
515
  // one slot returned (429/500/…), not a collapsed 502, and tag it with
@@ -499,18 +528,14 @@ export function proxyApi(
499
528
  );
500
529
  };
501
530
 
502
- // OpenAI surface: chat/completions + responses, plus its own (openai-slot)
503
- // model list.
531
+ // OpenAI surface: chat/completions + responses (/models is registered above,
532
+ // before the auth middleware, so it stays public).
504
533
  openai.post("/chat/completions", (c) => dispatch(c, "openai"));
505
534
  // OpenAI Responses API — its own routing slot (sources must be supportsResponses).
506
535
  openai.post("/responses", (c) => dispatch(c, "responses"));
507
- openai.get("/models", (c) => modelsList(c, store, "openai"));
508
536
 
509
- // Anthropic surface: messages, plus its own (anthropic-slot) model list so
510
- // an Anthropic client can discover models enabled only on the anthropic slot,
511
- // which the shared-/v1 design couldn't surface.
537
+ // Anthropic surface: messages (/models likewise registered above, public).
512
538
  anthropic.post("/messages", (c) => dispatch(c, "anthropic"));
513
- anthropic.get("/models", (c) => modelsList(c, store, "anthropic"));
514
539
 
515
540
  return { openai, anthropic };
516
541
  }
@@ -2,6 +2,7 @@ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSyn
2
2
  import { join } from "node:path";
3
3
  import { defaultConfig, newApiKey, CONFIG_VERSION } from "../shared/config";
4
4
  import type { GateConfig, LogEntry, Provider } from "../shared/types";
5
+ import { createLogger, type Logger } from "./logger";
5
6
 
6
7
  /** Call-log retention: the log is bounded two ways — never older than this, and
7
8
  * never more than LOG_MAX_LINES entries. Whichever binds first. 90 days covers
@@ -165,6 +166,10 @@ export class Store {
165
166
  private readonly dataPath: string;
166
167
  private readonly logsPath: string;
167
168
  private readonly credentialsPath: string;
169
+ private readonly serverLogPath: string;
170
+ /** Process-level runtime log (server.log — errors + notable events). Owned
171
+ * by the Store like every other dataDir file; injectable for quiet tests. */
172
+ private readonly logger: Logger;
168
173
  private chain: Promise<unknown> = Promise.resolve();
169
174
  /** Line count of the on-disk log (drives periodic trimming). The entries
170
175
  * themselves are persisted to logs.jsonl, never held in memory. */
@@ -179,11 +184,13 @@ export class Store {
179
184
  * NOT persisted (resets on restart). Pruned as `rpmUsed` reads. */
180
185
  private rpm = new Map<string, number[]>();
181
186
 
182
- constructor(dataDir: string) {
187
+ constructor(dataDir: string, opts: { logger?: Logger } = {}) {
183
188
  this.dataDir = dataDir;
184
189
  this.dataPath = join(dataDir, "data.json");
185
190
  this.logsPath = join(dataDir, "logs.jsonl");
186
191
  this.credentialsPath = join(dataDir, "credentials.txt");
192
+ this.serverLogPath = join(dataDir, "server.log");
193
+ this.logger = opts.logger ?? createLogger({ file: this.serverLogPath });
187
194
  this.data = this.load();
188
195
  this.logCount = this.countLogs();
189
196
  // Don't trim on the very first post-startup call: let normal hysteresis do
@@ -193,15 +200,22 @@ export class Store {
193
200
  }
194
201
 
195
202
  /** Resolved on-disk locations (for read-only display in Settings). */
196
- getPaths(): { dataDir: string; dataFile: string; logsFile: string; credentialsFile: string } {
203
+ getPaths(): { dataDir: string; dataFile: string; logsFile: string; credentialsFile: string; serverLogFile: string } {
197
204
  return {
198
205
  dataDir: this.dataDir,
199
206
  dataFile: this.dataPath,
200
207
  logsFile: this.logsPath,
201
208
  credentialsFile: this.credentialsPath,
209
+ serverLogFile: this.serverLogPath,
202
210
  };
203
211
  }
204
212
 
213
+ /** Runtime log (console + server.log). Errors and notable events only —
214
+ * per-call history is pushLog/logs.jsonl, a separate surface. */
215
+ getLogger(): Logger {
216
+ return this.logger;
217
+ }
218
+
205
219
  /**
206
220
  * Write a human-readable credentials.txt (web login + /v1 api key), current
207
221
  * as of this boot. So a brand-new user — or anyone who closed the startup