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 +18 -2
- package/README.zh-CN.md +18 -2
- package/package.json +1 -1
- package/packages/core/src/cli/index.ts +3 -1
- package/packages/core/src/server/app.ts +24 -2
- package/packages/core/src/server/auth.ts +5 -2
- package/packages/core/src/server/logger.ts +72 -0
- package/packages/core/src/server/proxy.ts +44 -19
- package/packages/core/src/server/store.ts +16 -2
- package/packages/web/dist/assets/{index-DrN9tuU6.js → index-B8MSV1WY.js} +25 -22
- package/packages/web/dist/index.html +1 -1
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,
|
|
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** 槽位的模型,
|
|
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.
|
|
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)
|
|
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
|
-
/**
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
|
304
|
-
|
|
305
|
-
.map(([id
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
503
|
-
//
|
|
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
|
|
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
|