myapikey 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.22.0",
3
+ "version": "0.23.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": [
@@ -2,7 +2,7 @@ import { Hono, type MiddlewareHandler } from "hono";
2
2
  import { newProviderId, newApiKey, trimBase } from "../shared/config";
3
3
  import type { ChainSlot, Format, FormatEntry, ModelEntry, Provider, RouteKey } from "../shared/types";
4
4
  import type { Store } from "./store";
5
- import { shortError, anthropicAuthHeaders } from "./proxy";
5
+ import { shortError, anthropicAuthHeaders, upstreamTarget, upstreamHeaders } from "./proxy";
6
6
  import { networkInterfaces } from "node:os";
7
7
 
8
8
  /** Best-effort LAN IPv4 of this host — the address an agent on another machine
@@ -323,6 +323,56 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
323
323
  }
324
324
  });
325
325
 
326
+ // Test a SOURCE directly: one minimal ping per selected protocol, straight to
327
+ // the upstream with this source's own key + base URL. Unlike the model tests
328
+ // (which loop back through dispatch) this needs no routing config, so an
329
+ // unrouted source can be checked too — and it takes no routing side-effects
330
+ // (no logs, no circuit, no pacing). ?model= is the upstream name sent
331
+ // verbatim; ?format= (repeatable) narrows the protocols, defaulting to every
332
+ // one the source supports (responses only when supportsResponses).
333
+ app.post("/providers/:id/test", async (c) => {
334
+ const p = store.get().providers.find((x) => x.id === c.req.param("id"));
335
+ if (!p) return c.json({ error: { message: "provider not found" } }, 404);
336
+ const model = (c.req.query("model") ?? "").trim();
337
+ if (!model) return c.json({ error: { message: "?model= (upstream model name) is required" } }, 400);
338
+ const wanted = (c.req.queries("format") ?? []).filter(
339
+ (f): f is RouteKey => f === "openai" || f === "anthropic" || f === "responses",
340
+ );
341
+ const formats: RouteKey[] = wanted.length
342
+ ? wanted
343
+ : [...p.formats, ...(p.supportsResponses ? ["responses" as const] : [])];
344
+ const results = await Promise.all(
345
+ formats.map(async (format) => {
346
+ if (format === "responses" ? !p.supportsResponses : !p.formats.includes(format as Format)) {
347
+ return { format, ok: false, status: 0, ms: 0, error: "source does not speak this format" };
348
+ }
349
+ const start = Date.now();
350
+ try {
351
+ // /responses takes `input`, not `messages` (same probe bodies as the
352
+ // model tests); max_tokens: 1 keeps the ping cheap.
353
+ const body =
354
+ format === "responses"
355
+ ? { model, input: "ping", stream: false }
356
+ : { model, messages: [{ role: "user", content: "ping" }], max_tokens: 1, stream: false };
357
+ const res = await fetch(upstreamTarget(p, format).url, {
358
+ method: "POST",
359
+ headers: upstreamHeaders(p, format === "responses" ? "openai" : format),
360
+ body: JSON.stringify(body),
361
+ });
362
+ const ms = Date.now() - start;
363
+ // Drain so the connection is released; the text feeds the error line.
364
+ const txt = await res.text().catch(() => "");
365
+ return res.ok
366
+ ? { format, ok: true, status: res.status, ms }
367
+ : { format, ok: false, status: res.status, ms, error: shortError(txt) || `HTTP ${res.status}` };
368
+ } catch (e) {
369
+ return { format, ok: false, status: 0, ms: Date.now() - start, error: `network error: ${(e as Error).message}` };
370
+ }
371
+ }),
372
+ );
373
+ return c.json({ results });
374
+ });
375
+
326
376
  // --- models ---
327
377
  app.get("/models", (c) => {
328
378
  const d = store.get();
@@ -219,7 +219,8 @@ export function anthropicAuthHeaders(apiKey: string, version: string): Record<st
219
219
  return { "x-api-key": apiKey, authorization: `Bearer ${apiKey}`, "anthropic-version": version };
220
220
  }
221
221
 
222
- function upstreamHeaders(provider: Provider, format: Format, clientVersion?: string): Record<string, string> {
222
+ /** Exported for the admin source-test (direct upstream ping, no routing). */
223
+ export function upstreamHeaders(provider: Provider, format: Format, clientVersion?: string): Record<string, string> {
223
224
  const h: Record<string, string> = { "content-type": "application/json" };
224
225
  if (format === "openai") h.authorization = `Bearer ${provider.apiKey}`;
225
226
  else Object.assign(h, anthropicAuthHeaders(provider.apiKey, clientVersion || "2023-06-01"));
@@ -228,8 +229,9 @@ function upstreamHeaders(provider: Provider, format: Format, clientVersion?: str
228
229
 
229
230
  /** Resolve the upstream URL + wire format for a routing slot. The OpenAI base
230
231
  * includes the version segment (we append the bare resource); the Anthropic
231
- * base excludes /v1 (we append v1/messages). /responses reuses the OpenAI base. */
232
- function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format } {
232
+ * base excludes /v1 (we append v1/messages). /responses reuses the OpenAI base.
233
+ * Exported for the admin source-test (direct upstream ping, no routing). */
234
+ export function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format } {
233
235
  if (key === "anthropic") {
234
236
  return { url: `${trimBase(p.baseUrlAnthropic)}/v1/messages`, wire: "anthropic" };
235
237
  }