myapikey 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.
@@ -0,0 +1,518 @@
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
+ import { newProviderId, newApiKey, trimBase } from "../shared/config";
3
+ import type { Format, FormatEntry, Provider, RouteKey } from "../shared/types";
4
+ import type { Store } from "./store";
5
+ import { shortError, anthropicAuthHeaders } from "./proxy";
6
+ import { networkInterfaces } from "node:os";
7
+
8
+ /** Best-effort LAN IPv4 of this host — the address an agent on another machine
9
+ * can actually reach (localhost is useless to it). */
10
+ function detectLanIp(): string | null {
11
+ const candidates: string[] = [];
12
+ for (const list of Object.values(networkInterfaces())) {
13
+ if (!list) continue;
14
+ for (const n of list) {
15
+ if (n.family !== "IPv4" || n.internal) continue;
16
+ if (n.address.startsWith("169.254.")) continue; // link-local
17
+ candidates.push(n.address);
18
+ }
19
+ }
20
+ const pick = candidates.find((a) => a.startsWith("192.168.") || a.startsWith("10."));
21
+ return pick ?? candidates[0] ?? null;
22
+ }
23
+
24
+ function mask(key: string): string {
25
+ if (!key) return "";
26
+ return key.length <= 4 ? "••••" : "••••" + key.slice(-4);
27
+ }
28
+
29
+ /** Order-insensitive signature of a formats list, for change detection. */
30
+ function formatsKey(f: Format[]): string {
31
+ return [...f].sort().join(",");
32
+ }
33
+
34
+ /** Whether a provider is a valid source for a routing slot: openai/anthropic
35
+ * require that wire format; responses requires supportsResponses. */
36
+ function providerSpeaks(p: Provider, key: RouteKey): boolean {
37
+ return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
38
+ }
39
+
40
+ /** Drop a provider id from a routing slot: remove it from the chain AND from any
41
+ * modelMap (so a stale upstream-name override doesn't linger after the provider
42
+ * is gone or removed from this model's chain). */
43
+ function purgeProvider(fe: FormatEntry, pid: string): void {
44
+ fe.providers = fe.providers.filter((x) => x !== pid);
45
+ if (fe.modelMap && pid in fe.modelMap) {
46
+ delete fe.modelMap[pid];
47
+ if (!Object.keys(fe.modelMap).length) delete fe.modelMap;
48
+ }
49
+ }
50
+
51
+ /** Project provider for API responses: hide the full key. */
52
+ function toPublic(p: Provider) {
53
+ return {
54
+ id: p.id,
55
+ name: p.name,
56
+ baseUrlOpenai: p.baseUrlOpenai,
57
+ baseUrlAnthropic: p.baseUrlAnthropic,
58
+ formats: p.formats,
59
+ supportsResponses: p.supportsResponses ?? false,
60
+ apiKey: mask(p.apiKey),
61
+ discoveredModels: p.discoveredModels ?? [],
62
+ discoveredAt: p.discoveredAt ?? null,
63
+ createdAt: p.createdAt,
64
+ };
65
+ }
66
+
67
+ async function readJson<T = unknown>(req: Request): Promise<T | null> {
68
+ try {
69
+ return (await req.json()) as T;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Fetch a provider's model list. Tries OpenAI-style first, then Anthropic-style. */
76
+ export async function discoverModels(p: Provider): Promise<string[]> {
77
+ const tryFetch = async (base: string, suffix: string, headers: Record<string, string>) => {
78
+ const res = await fetch(`${trimBase(base)}/${suffix}`, { method: "GET", headers });
79
+ if (!res.ok) return null;
80
+ const json = (await res.json()) as { data?: { id?: string }[] };
81
+ if (Array.isArray(json.data)) return json.data.map((m) => m.id).filter((x): x is string => !!x);
82
+ return null;
83
+ };
84
+ // Each format probes its own base + suffix. OpenAI lists at /models; Anthropic
85
+ // at /v1/models (its base excludes /v1). Both return the {data:[{id}]} shape.
86
+ const attempts: { base: string; suffix: string; headers: Record<string, string> }[] = [];
87
+ if (p.formats.includes("openai"))
88
+ attempts.push({ base: p.baseUrlOpenai, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
89
+ if (p.formats.includes("anthropic"))
90
+ attempts.push({ base: p.baseUrlAnthropic, suffix: "v1/models", headers: anthropicAuthHeaders(p.apiKey, "2023-06-01") });
91
+ if (!attempts.length)
92
+ attempts.push({ base: p.baseUrlOpenai, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
93
+
94
+ for (const a of attempts) {
95
+ const ids = await tryFetch(a.base, a.suffix, a.headers);
96
+ if (ids && ids.length) return ids;
97
+ }
98
+ return [];
99
+ }
100
+
101
+ /**
102
+ * Re-run discovery for a provider and persist the result.
103
+ * Network fetch happens OUTSIDE store.update so the write-chain isn't held open.
104
+ * Returns the discovered model ids; on fetch failure returns [] but still
105
+ * records the attempt (discoveredAt), so the UI can tell it was tried.
106
+ */
107
+ async function refreshDiscovery(store: Store, id: string): Promise<string[]> {
108
+ const p = store.get().providers.find((x) => x.id === id);
109
+ if (!p) return [];
110
+ let models: string[] = [];
111
+ let failed = false;
112
+ try {
113
+ models = await discoverModels(p);
114
+ } catch {
115
+ failed = true;
116
+ }
117
+ await store.update((d) => {
118
+ const pp = d.providers.find((x) => x.id === id);
119
+ if (pp) {
120
+ pp.discoveredModels = failed ? pp.discoveredModels ?? [] : models;
121
+ pp.discoveredAt = Date.now();
122
+ }
123
+ });
124
+ return failed ? (store.get().providers.find((x) => x.id === id)?.discoveredModels ?? []) : models;
125
+ }
126
+
127
+ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono {
128
+ const app = new Hono();
129
+ app.use("*", auth);
130
+
131
+ // --- account ---
132
+ app.get("/account", (c) => {
133
+ const a = store.get().account;
134
+ return c.json({ username: a.username, password: a.password });
135
+ });
136
+
137
+ // Update username and/or password. Either field is optional (omit to keep the
138
+ // current value), mirroring the provider PUT "blank = keep" convention.
139
+ // The auth middleware reads the account live from the store, so a change takes
140
+ // effect immediately without a restart. (The call itself is authed with the
141
+ // old credentials; web clients must refresh their cached creds afterward.)
142
+ app.put("/account", async (c) => {
143
+ const body = await readJson<{ username?: string; password?: string }>(c.req.raw);
144
+ if (!body) return c.json({ error: { message: "username and/or password required" } }, 400);
145
+ const username = body.username?.trim();
146
+ const password = body.password;
147
+ if (body.username !== undefined && !username) {
148
+ return c.json({ error: { message: "username must not be empty" } }, 400);
149
+ }
150
+ if (password !== undefined && password.length < 8) {
151
+ return c.json({ error: { message: "password must be at least 8 characters" } }, 400);
152
+ }
153
+ if (username === undefined && password === undefined) {
154
+ return c.json({ error: { message: "nothing to update" } }, 400);
155
+ }
156
+ await store.update((d) => {
157
+ if (username !== undefined) d.account.username = username;
158
+ if (password !== undefined) d.account.password = password;
159
+ });
160
+ return c.json({ ok: true });
161
+ });
162
+
163
+ // --- api key (separate from the account password; what /v1 checks) ---
164
+ app.get("/api-key", (c) => c.json({ apiKey: store.get().apiKey }));
165
+ app.get("/connection", (c) => c.json({ lanIp: detectLanIp() }));
166
+
167
+ app.post("/api-key/rotate", async (c) => {
168
+ const apiKey = newApiKey();
169
+ await store.update((d) => {
170
+ d.apiKey = apiKey;
171
+ });
172
+ return c.json({ apiKey });
173
+ });
174
+
175
+ // --- providers ---
176
+ app.get("/providers", (c) => c.json({ providers: store.get().providers.map(toPublic) }));
177
+
178
+ app.post("/providers", async (c) => {
179
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean }>(c.req.raw);
180
+ const formats = body?.formats ?? [];
181
+ const needOpenai = formats.includes("openai");
182
+ const needAnthropic = formats.includes("anthropic");
183
+ if (!body?.name || !body?.apiKey || !formats.length) {
184
+ return c.json({ error: { message: "name, apiKey, formats are required" } }, 400);
185
+ }
186
+ if ((needOpenai && !body.baseUrlOpenai) || (needAnthropic && !body.baseUrlAnthropic)) {
187
+ return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
188
+ }
189
+ const id = newProviderId();
190
+ const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
191
+ const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
192
+ await store.update((d) => {
193
+ d.providers.push({
194
+ id,
195
+ name: body.name!,
196
+ baseUrlOpenai,
197
+ baseUrlAnthropic,
198
+ apiKey: body.apiKey!,
199
+ formats,
200
+ supportsResponses: body.supportsResponses === true,
201
+ createdAt: Date.now(),
202
+ });
203
+ });
204
+ // Auto-discover so the user immediately sees what this source offers.
205
+ const discovered = await refreshDiscovery(store, id).catch(() => [] as string[]);
206
+ const created = store.get().providers.find((x) => x.id === id)!;
207
+ return c.json({ provider: toPublic(created), discovered }, 201);
208
+ });
209
+
210
+ app.put("/providers/:id", async (c) => {
211
+ const id = c.req.param("id");
212
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean }>(c.req.raw);
213
+ const formats = body?.formats ?? [];
214
+ const needOpenai = formats.includes("openai");
215
+ const needAnthropic = formats.includes("anthropic");
216
+ if (!body?.name || !formats.length) {
217
+ return c.json({ error: { message: "name, formats are required" } }, 400);
218
+ }
219
+ if ((needOpenai && !body.baseUrlOpenai) || (needAnthropic && !body.baseUrlAnthropic)) {
220
+ return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
221
+ }
222
+ const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
223
+ const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
224
+ let rediscover = false;
225
+ await store.update((d) => {
226
+ const p = d.providers.find((x) => x.id === id);
227
+ if (!p) return;
228
+ // apiKey is optional on edit: omit to keep the existing key (we only ever
229
+ // expose a masked key to clients, so they can't send the real one back).
230
+ const newKey = body.apiKey ? body.apiKey : p.apiKey;
231
+ rediscover =
232
+ p.baseUrlOpenai !== baseUrlOpenai ||
233
+ p.baseUrlAnthropic !== baseUrlAnthropic ||
234
+ (!!body.apiKey && p.apiKey !== body.apiKey) ||
235
+ formatsKey(p.formats) !== formatsKey(formats);
236
+ p.name = body.name!;
237
+ p.baseUrlOpenai = baseUrlOpenai;
238
+ p.baseUrlAnthropic = baseUrlAnthropic;
239
+ p.apiKey = newKey;
240
+ p.formats = formats;
241
+ if (body.supportsResponses !== undefined) p.supportsResponses = body.supportsResponses;
242
+ });
243
+ const found = store.get().providers.find((x) => x.id === id);
244
+ if (!found) return c.json({ error: { message: "provider not found" } }, 404);
245
+ if (rediscover) await refreshDiscovery(store, id).catch(() => {});
246
+ return c.json({ provider: toPublic(store.get().providers.find((x) => x.id === id)!) });
247
+ });
248
+
249
+ app.delete("/providers/:id", async (c) => {
250
+ const id = c.req.param("id");
251
+ let found = false;
252
+ await store.update((d) => {
253
+ found = d.providers.some((p) => p.id === id);
254
+ d.providers = d.providers.filter((p) => p.id !== id);
255
+ for (const m of Object.values(d.models)) {
256
+ purgeProvider(m.openai, id);
257
+ purgeProvider(m.anthropic, id);
258
+ purgeProvider(m.responses, id);
259
+ }
260
+ });
261
+ if (!found) return c.json({ error: { message: "provider not found" } }, 404);
262
+ return c.json({ ok: true });
263
+ });
264
+
265
+ app.post("/providers/:id/discover", async (c) => {
266
+ const id = c.req.param("id");
267
+ const p = store.get().providers.find((x) => x.id === id);
268
+ if (!p) return c.json({ error: { message: "provider not found" } }, 404);
269
+ try {
270
+ const models = await refreshDiscovery(store, id);
271
+ return c.json({ models });
272
+ } catch (e) {
273
+ return c.json({ error: { message: `discovery failed: ${(e as Error).message}`, models: [] } }, 502);
274
+ }
275
+ });
276
+
277
+ // --- models ---
278
+ app.get("/models", (c) => {
279
+ const d = store.get();
280
+ const byId = new Map(d.providers.map((p) => [p.id, p]));
281
+ const proj = (fe: FormatEntry) => ({
282
+ enabled: fe.enabled,
283
+ providers: fe.providers.map((pid) => ({
284
+ id: pid,
285
+ name: byId.get(pid)?.name ?? "?",
286
+ // Upstream model name this source is mapped to (undefined = send the
287
+ // public name verbatim). Flattened out of modelMap for the client.
288
+ model: fe.modelMap?.[pid],
289
+ })),
290
+ });
291
+ const models = Object.entries(d.models).map(([name, e]) => ({
292
+ name,
293
+ openai: proj(e.openai),
294
+ anthropic: proj(e.anthropic),
295
+ responses: proj(e.responses),
296
+ }));
297
+ return c.json({ models });
298
+ });
299
+
300
+ // Enable a model on ONE routing slot (and optionally seed its chain). Creates
301
+ // the entry if absent (all slots start disabled); never touches other slots.
302
+ // Every requested provider must exist and be compatible with the slot.
303
+ app.post("/models", async (c) => {
304
+ const body = await readJson<{ name?: string; format?: RouteKey; providers?: string[] }>(c.req.raw);
305
+ if (!body?.name) return c.json({ error: { message: "name is required" } }, 400);
306
+ if (!body.format) return c.json({ error: { message: "format is required (openai, anthropic, or responses)" } }, 400);
307
+ const name = body.name;
308
+ const key = body.format;
309
+ const cfg = store.get();
310
+ const requested = body.providers ?? [];
311
+ for (const pid of requested) {
312
+ const p = cfg.providers.find((x) => x.id === pid);
313
+ if (!p) return c.json({ error: { message: `provider not found: ${pid}` } }, 400);
314
+ if (!providerSpeaks(p, key))
315
+ return c.json({ error: { message: `provider ${p.name} does not serve ${key}` } }, 400);
316
+ }
317
+ await store.update((d) => {
318
+ const entry = (d.models[name] ??= {
319
+ openai: { enabled: false, providers: [] },
320
+ anthropic: { enabled: false, providers: [] },
321
+ responses: { enabled: false, providers: [] },
322
+ });
323
+ const fe = entry[key];
324
+ for (const pid of requested) if (!fe.providers.includes(pid)) fe.providers.push(pid);
325
+ fe.enabled = true;
326
+ });
327
+ return c.json({ ok: true }, 201);
328
+ });
329
+
330
+ app.post("/models/:name/providers", async (c) => {
331
+ const name = c.req.param("name");
332
+ const body = await readJson<{ format?: RouteKey; providerId?: string }>(c.req.raw);
333
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
334
+ if (!body?.providerId) return c.json({ error: { message: "providerId is required" } }, 400);
335
+ const cfg = store.get();
336
+ const p = cfg.providers.find((x) => x.id === body.providerId);
337
+ if (!p) return c.json({ error: { message: "provider not found" } }, 400);
338
+ if (!providerSpeaks(p, body.format))
339
+ return c.json({ error: { message: `provider ${p.name} does not serve ${body.format}` } }, 400);
340
+ let errStatus = 0;
341
+ await store.update((d) => {
342
+ const entry = d.models[name];
343
+ if (!entry) {
344
+ errStatus = 404;
345
+ return;
346
+ }
347
+ if (!entry[body.format!].providers.includes(body.providerId!)) entry[body.format!].providers.push(body.providerId!);
348
+ });
349
+ if (errStatus === 404) return c.json({ error: { message: "model not found; enable it first" } }, 404);
350
+ return c.json({ ok: true });
351
+ });
352
+
353
+ app.delete("/models/:name/providers/:providerId", async (c) => {
354
+ const name = c.req.param("name");
355
+ const pid = c.req.param("providerId");
356
+ const format = c.req.query("format") as RouteKey | undefined;
357
+ if (!format) return c.json({ error: { message: "?format=openai|anthropic|responses is required" } }, 400);
358
+ await store.update((d) => {
359
+ const entry = d.models[name];
360
+ if (entry) purgeProvider(entry[format], pid);
361
+ });
362
+ return c.json({ ok: true });
363
+ });
364
+
365
+ app.put("/models/:name/priority", async (c) => {
366
+ const name = c.req.param("name");
367
+ const body = await readJson<{ format?: RouteKey; providers?: string[] }>(c.req.raw);
368
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
369
+ if (!Array.isArray(body?.providers)) return c.json({ error: { message: "providers[] required" } }, 400);
370
+ const format = body.format;
371
+ let errStatus = 0;
372
+ let errMsg = "";
373
+ await store.update((d) => {
374
+ const entry = d.models[name];
375
+ if (!entry) {
376
+ errStatus = 404;
377
+ return;
378
+ }
379
+ const fe = entry[format];
380
+ // Reorder only: the submitted list must be a permutation of the current
381
+ // chain (use add-provider / remove-provider to change membership).
382
+ const cur = new Set(fe.providers);
383
+ if (body.providers!.length !== cur.size || body.providers!.some((pid) => !cur.has(pid))) {
384
+ errStatus = 400;
385
+ errMsg = "providers must be a reordering of the current chain (no add/drop)";
386
+ return;
387
+ }
388
+ fe.providers = body.providers!;
389
+ });
390
+ if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
391
+ if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
392
+ return c.json({ ok: true });
393
+ });
394
+
395
+ // Set (or clear) a model×source upstream-model mapping. `model` is the name
396
+ // sent upstream when forwarding this public model to this provider; an empty
397
+ // string clears it (back to identity — send the public name). The provider
398
+ // must already be in this slot's chain: you map a source already attached.
399
+ app.put("/models/:name/map", async (c) => {
400
+ const name = c.req.param("name");
401
+ const body = await readJson<{ format?: RouteKey; providerId?: string; model?: string }>(c.req.raw);
402
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
403
+ if (!body?.providerId) return c.json({ error: { message: "providerId is required" } }, 400);
404
+ const format = body.format;
405
+ const pid = body.providerId;
406
+ const upstream = (body.model ?? "").trim();
407
+ let errStatus = 0;
408
+ let errMsg = "";
409
+ await store.update((d) => {
410
+ const entry = d.models[name];
411
+ if (!entry) {
412
+ errStatus = 404;
413
+ errMsg = "model not found";
414
+ return;
415
+ }
416
+ const fe = entry[format];
417
+ if (!fe.providers.includes(pid)) {
418
+ errStatus = 400;
419
+ errMsg = "provider is not in this model's chain for the given format";
420
+ return;
421
+ }
422
+ if (upstream) {
423
+ (fe.modelMap ??= {})[pid] = upstream;
424
+ } else if (fe.modelMap && pid in fe.modelMap) {
425
+ delete fe.modelMap[pid];
426
+ if (!Object.keys(fe.modelMap).length) delete fe.modelMap;
427
+ }
428
+ });
429
+ if (errStatus === 404) return c.json({ error: { message: errMsg } }, 404);
430
+ if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
431
+ return c.json({ ok: true });
432
+ });
433
+
434
+ app.post("/models/:name/disable", async (c) => {
435
+ const name = c.req.param("name");
436
+ const body = await readJson<{ format?: RouteKey }>(c.req.raw);
437
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
438
+ const format = body.format;
439
+ await store.update((d) => {
440
+ if (d.models[name]) d.models[name][format].enabled = false;
441
+ });
442
+ return c.json({ ok: true });
443
+ });
444
+
445
+ // Probe a model end-to-end by driving the REAL /v1 path: an in-process
446
+ // loopback through the proxy sub-app (api-key auth → dispatch → upstream →
447
+ // failover) with the gateway's own api key. It runs the same code a real agent
448
+ // call runs — no mirrored dispatch logic — so the result is true ground truth,
449
+ // it shows up in recent calls like any real call, and it catches a
450
+ // broken/rotated gateway key (which a direct-upstream probe could not).
451
+ // dispatch reports which provider answered via x-myapikey-provider.
452
+ app.post("/models/:name/test", async (c) => {
453
+ const name = c.req.param("name");
454
+ const cfg = store.get();
455
+ if (!cfg.models[name]) return c.json({ error: { message: "model not found" } }, 404);
456
+ let format = c.req.query("format") as RouteKey | undefined;
457
+ if (!format) {
458
+ // No slot requested: pick the first one the model is enabled on.
459
+ const entry = cfg.models[name];
460
+ for (const k of ["openai", "anthropic", "responses"] as RouteKey[]) if (entry[k]?.enabled) { format = k; break; }
461
+ }
462
+ if (!format) {
463
+ return c.json({ result: { ok: false, status: 0, format: "openai", error: "model not enabled on any routing slot" } });
464
+ }
465
+ const path = format === "anthropic" ? "/messages" : format === "responses" ? "/responses" : "/chat/completions";
466
+ // /responses is the OpenAI Responses API — it takes `input`, not `messages`.
467
+ const body =
468
+ format === "responses"
469
+ ? { model: name, input: "ping", stream: false }
470
+ : { model: name, messages: [{ role: "user", content: "ping" }], max_tokens: 1, stream: false };
471
+ let res: Response;
472
+ try {
473
+ res = await v1.request(path, {
474
+ method: "POST",
475
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1" },
476
+ body: JSON.stringify(body),
477
+ });
478
+ } catch (e) {
479
+ return c.json({ result: { ok: false, status: 0, format, error: `gateway loopback failed: ${(e as Error).message}` } });
480
+ }
481
+ const provider = res.headers.get("x-myapikey-provider") ?? undefined;
482
+ if (res.ok) return c.json({ result: { ok: true, status: res.status, provider, format } });
483
+ const txt = await res.text().catch(() => "");
484
+ return c.json({ result: { ok: false, status: res.status, provider, format, error: shortError(txt) || `HTTP ${res.status}` } });
485
+ });
486
+
487
+ app.delete("/models/:name", async (c) => {
488
+ const name = c.req.param("name");
489
+ await store.update((d) => {
490
+ delete d.models[name];
491
+ });
492
+ return c.json({ ok: true });
493
+ });
494
+
495
+ // --- logs ---
496
+ app.get("/logs", (c) => c.json({ logs: store.getLogs() }));
497
+
498
+ // --- stats (aggregate over the retained call history; never polled) ---
499
+ app.get("/stats", (c) => {
500
+ const r = c.req.query("range") ?? "7d";
501
+ const DAY = 24 * 60 * 60 * 1000;
502
+ const rangeMs =
503
+ r === "24h" ? DAY : r === "7d" ? 7 * DAY : r === "30d" ? 30 * DAY : r === "90d" ? 90 * DAY : 0; // 0 = "all"
504
+ return c.json(store.getStats(rangeMs));
505
+ });
506
+
507
+ // --- storage (read-only: where data.json + logs.jsonl live) ---
508
+ app.get("/storage", (c) => c.json(store.getPaths()));
509
+
510
+ // --- circuit breaker (read-only snapshot + manual reset) ---
511
+ app.get("/circuit", (c) => c.json({ providers: store.circuitState() }));
512
+ app.post("/circuit/:id/reset", (c) => {
513
+ store.resetCircuit(c.req.param("id"));
514
+ return c.json({ ok: true });
515
+ });
516
+
517
+ return app;
518
+ }
@@ -0,0 +1,55 @@
1
+ import { Hono } from "hono";
2
+ import { serveStatic } from "@hono/node-server/serve-static";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { authMiddleware, apiKeyMiddleware } from "./auth";
6
+ import { adminApi } from "./admin";
7
+ import { proxyApi } from "./proxy";
8
+ import type { Store } from "./store";
9
+
10
+ export interface AppOptions {
11
+ /** Absolute path to a built web dist dir (optional). */
12
+ webDir?: string;
13
+ }
14
+
15
+ export function createApp(store: Store, opts: AppOptions = {}): Hono {
16
+ const app = new Hono();
17
+
18
+ app.get("/health", (c) => c.json({ ok: true, version: "0.1.0" }));
19
+
20
+ // Two independent secrets: the account password admins /admin (Basic), the
21
+ // API key gates /v1 (Bearer / x-api-key). Neither works on the other's surface.
22
+ const accountAuth = authMiddleware(
23
+ () => store.get().account.username,
24
+ () => store.get().account.password,
25
+ );
26
+ const apiKeyAuth = apiKeyMiddleware(() => store.get().apiKey);
27
+
28
+ // Both sub-apps require auth, applied inside each sub-app (before routes).
29
+ const v1 = proxyApi(store, apiKeyAuth);
30
+ const admin = adminApi(store, accountAuth, v1);
31
+
32
+ app.route("/v1", v1);
33
+ app.route("/admin", admin);
34
+
35
+ // Web UI: serve built SPA when available.
36
+ if (opts.webDir && existsSync(opts.webDir)) {
37
+ app.use("/*", serveStatic({ root: opts.webDir }));
38
+ app.get("*", async (c) => {
39
+ try {
40
+ return c.html(await readFile(`${opts.webDir}/index.html`, "utf8"));
41
+ } catch {
42
+ return c.notFound();
43
+ }
44
+ });
45
+ } else {
46
+ app.get("*", (c) =>
47
+ c.text(
48
+ "MyAPIKey is running. Web UI not built — run `npm run build:web`. API at /v1 (proxy) and /admin (config).",
49
+ 404,
50
+ ),
51
+ );
52
+ }
53
+
54
+ return app;
55
+ }
@@ -0,0 +1,74 @@
1
+ import type { Context, MiddlewareHandler } from "hono";
2
+ import { timingSafeEqual } from "node:crypto";
3
+
4
+ /** Constant-time string compare. */
5
+ function safeEqual(a: string, b: string): boolean {
6
+ const ab = Buffer.from(a);
7
+ const bb = Buffer.from(b);
8
+ if (ab.length !== bb.length) return false;
9
+ return timingSafeEqual(ab, bb);
10
+ }
11
+
12
+ /**
13
+ * Extract the presented secret from any of the three headers an agent SDK might
14
+ * send: Authorization: Bearer <pw>, x-api-key: <pw>, or HTTP Basic.
15
+ * Returns null if no credential is present.
16
+ */
17
+ export function extractSecret(c: Context): { password: string; username?: string } | null {
18
+ const xKey = c.req.header("x-api-key");
19
+ if (xKey) return { password: xKey };
20
+
21
+ const auth = c.req.header("authorization") ?? "";
22
+ const lower = auth.toLowerCase();
23
+ if (lower.startsWith("bearer ")) return { password: auth.slice(7).trim() };
24
+ if (lower.startsWith("basic ")) {
25
+ try {
26
+ const decoded = Buffer.from(auth.slice(6).trim(), "base64").toString("utf8");
27
+ const idx = decoded.indexOf(":");
28
+ if (idx === -1) return null;
29
+ return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Hono middleware: require the single account/password. */
38
+ export function authMiddleware(getUser: () => string, getPass: () => string): MiddlewareHandler {
39
+ return async (c, next) => {
40
+ const cred = extractSecret(c);
41
+ const ok =
42
+ !!cred &&
43
+ (cred.username === undefined || safeEqual(cred.username, getUser())) &&
44
+ safeEqual(cred.password, getPass());
45
+ if (!ok) {
46
+ return c.json(
47
+ { error: { message: "invalid or missing credentials", type: "authentication_error" } },
48
+ 401,
49
+ );
50
+ }
51
+ await next();
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Hono middleware: require the API key (for /v1). Accepts Bearer / x-api-key
57
+ * but NOT HTTP Basic — Basic carries account credentials, which are a separate
58
+ * secret. extractSecret sets `username` only for Basic, so rejecting when it's
59
+ * present keeps the account password off /v1.
60
+ */
61
+ export function apiKeyMiddleware(getKey: () => string): MiddlewareHandler {
62
+ return async (c, next) => {
63
+ const cred = extractSecret(c);
64
+ const ok =
65
+ !!cred && cred.username === undefined && !!cred.password && safeEqual(cred.password, getKey());
66
+ if (!ok) {
67
+ return c.json(
68
+ { error: { message: "invalid or missing api key", type: "authentication_error" } },
69
+ 401,
70
+ );
71
+ }
72
+ await next();
73
+ };
74
+ }