myapikey 0.47.1 → 0.48.1
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 +1 -1
- package/packages/core/src/server/admin.ts +23 -22
- package/packages/core/src/server/proxy.ts +28 -27
- package/packages/core/src/server/store.ts +28 -2
- package/packages/core/src/shared/config.ts +1 -1
- package/packages/core/src/shared/types.ts +14 -13
- package/packages/web/dist/assets/index-CIGEPkrP.js +346 -0
- package/packages/web/dist/assets/index-CWGc5RaE.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-C6uBvekt.js +0 -346
- package/packages/web/dist/assets/index-Ciqu1UcO.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
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": [
|
|
@@ -27,7 +27,7 @@ function mask(key: string): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** Order-insensitive signature of a formats list, for change detection. */
|
|
30
|
-
function formatsKey(f:
|
|
30
|
+
function formatsKey(f: RouteKey[]): string {
|
|
31
31
|
return [...f].sort().join(",");
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -38,10 +38,10 @@ function coerceRpm(v: unknown): number | undefined {
|
|
|
38
38
|
return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Whether a provider is a valid source for a routing slot:
|
|
42
|
-
*
|
|
41
|
+
/** Whether a provider is a valid source for a routing slot: it must still offer
|
|
42
|
+
* that format (formats and base URLs live and move together). */
|
|
43
43
|
function providerSpeaks(p: Provider, key: RouteKey): boolean {
|
|
44
|
-
return
|
|
44
|
+
return p.formats.includes(key);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** The body fields a chain slot's sampling default may set — the wire-agnostic
|
|
@@ -96,8 +96,8 @@ function toPublic(p: Provider) {
|
|
|
96
96
|
name: p.name,
|
|
97
97
|
baseUrlOpenai: p.baseUrlOpenai,
|
|
98
98
|
baseUrlAnthropic: p.baseUrlAnthropic,
|
|
99
|
+
baseUrlResponses: p.baseUrlResponses,
|
|
99
100
|
formats: p.formats,
|
|
100
|
-
supportsResponses: p.supportsResponses ?? false,
|
|
101
101
|
apiKey: mask(p.apiKey),
|
|
102
102
|
rpm: p.rpm ?? 0,
|
|
103
103
|
discoveredModels: p.discoveredModels ?? [],
|
|
@@ -129,6 +129,10 @@ export async function discoverModels(p: Provider): Promise<string[]> {
|
|
|
129
129
|
const attempts: { base: string; suffix: string; headers: Record<string, string> }[] = [];
|
|
130
130
|
if (p.formats.includes("openai"))
|
|
131
131
|
attempts.push({ base: p.baseUrlOpenai, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
|
|
132
|
+
// Responses usually shares the chat list, so it's tried after openai — only
|
|
133
|
+
// reached when the chat base is unset (responses-only source) or empty-handed.
|
|
134
|
+
if (p.formats.includes("responses") && p.baseUrlResponses)
|
|
135
|
+
attempts.push({ base: p.baseUrlResponses, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
|
|
132
136
|
if (p.formats.includes("anthropic"))
|
|
133
137
|
attempts.push({ base: p.baseUrlAnthropic, suffix: "v1/models", headers: anthropicAuthHeaders(p.apiKey, "2023-06-01") });
|
|
134
138
|
if (!attempts.length)
|
|
@@ -246,19 +250,18 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
246
250
|
app.get("/providers", (c) => c.json({ providers: store.get().providers.map(toPublic) }));
|
|
247
251
|
|
|
248
252
|
app.post("/providers", async (c) => {
|
|
249
|
-
const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?:
|
|
253
|
+
const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; baseUrlResponses?: string; apiKey?: string; formats?: RouteKey[]; rpm?: number }>(c.req.raw);
|
|
250
254
|
const formats = body?.formats ?? [];
|
|
251
|
-
const needOpenai = formats.includes("openai");
|
|
252
|
-
const needAnthropic = formats.includes("anthropic");
|
|
253
255
|
if (!body?.name || !body?.apiKey || !formats.length) {
|
|
254
256
|
return c.json({ error: { message: "name, apiKey, formats are required" } }, 400);
|
|
255
257
|
}
|
|
256
|
-
if ((
|
|
258
|
+
if ((formats.includes("openai") && !body.baseUrlOpenai) || (formats.includes("anthropic") && !body.baseUrlAnthropic) || (formats.includes("responses") && !body.baseUrlResponses)) {
|
|
257
259
|
return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
|
|
258
260
|
}
|
|
259
261
|
const id = newProviderId();
|
|
260
262
|
const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
|
|
261
263
|
const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
|
|
264
|
+
const baseUrlResponses = trimBase(body.baseUrlResponses ?? "");
|
|
262
265
|
const rpm = coerceRpm(body!.rpm);
|
|
263
266
|
await store.update((d) => {
|
|
264
267
|
d.providers.push({
|
|
@@ -266,9 +269,9 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
266
269
|
name: body.name!,
|
|
267
270
|
baseUrlOpenai,
|
|
268
271
|
baseUrlAnthropic,
|
|
272
|
+
baseUrlResponses,
|
|
269
273
|
apiKey: body.apiKey!,
|
|
270
274
|
formats,
|
|
271
|
-
supportsResponses: body.supportsResponses === true,
|
|
272
275
|
...(rpm ? { rpm } : {}),
|
|
273
276
|
createdAt: Date.now(),
|
|
274
277
|
});
|
|
@@ -281,18 +284,17 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
281
284
|
|
|
282
285
|
app.put("/providers/:id", async (c) => {
|
|
283
286
|
const id = c.req.param("id");
|
|
284
|
-
const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?:
|
|
287
|
+
const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; baseUrlResponses?: string; apiKey?: string; formats?: RouteKey[]; rpm?: number }>(c.req.raw);
|
|
285
288
|
const formats = body?.formats ?? [];
|
|
286
|
-
const needOpenai = formats.includes("openai");
|
|
287
|
-
const needAnthropic = formats.includes("anthropic");
|
|
288
289
|
if (!body?.name || !formats.length) {
|
|
289
290
|
return c.json({ error: { message: "name, formats are required" } }, 400);
|
|
290
291
|
}
|
|
291
|
-
if ((
|
|
292
|
+
if ((formats.includes("openai") && !body.baseUrlOpenai) || (formats.includes("anthropic") && !body.baseUrlAnthropic) || (formats.includes("responses") && !body.baseUrlResponses)) {
|
|
292
293
|
return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
|
|
293
294
|
}
|
|
294
295
|
const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
|
|
295
296
|
const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
|
|
297
|
+
const baseUrlResponses = trimBase(body.baseUrlResponses ?? "");
|
|
296
298
|
let rediscover = false;
|
|
297
299
|
await store.update((d) => {
|
|
298
300
|
const p = d.providers.find((x) => x.id === id);
|
|
@@ -303,14 +305,15 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
303
305
|
rediscover =
|
|
304
306
|
p.baseUrlOpenai !== baseUrlOpenai ||
|
|
305
307
|
p.baseUrlAnthropic !== baseUrlAnthropic ||
|
|
308
|
+
p.baseUrlResponses !== baseUrlResponses ||
|
|
306
309
|
(!!body.apiKey && p.apiKey !== body.apiKey) ||
|
|
307
310
|
formatsKey(p.formats) !== formatsKey(formats);
|
|
308
311
|
p.name = body.name!;
|
|
309
312
|
p.baseUrlOpenai = baseUrlOpenai;
|
|
310
313
|
p.baseUrlAnthropic = baseUrlAnthropic;
|
|
314
|
+
p.baseUrlResponses = baseUrlResponses;
|
|
311
315
|
p.apiKey = newKey;
|
|
312
316
|
p.formats = formats;
|
|
313
|
-
if (body.supportsResponses !== undefined) p.supportsResponses = body.supportsResponses;
|
|
314
317
|
// rpm: present in the body → set (0/invalid clears to unlimited); absent → keep.
|
|
315
318
|
if (body!.rpm !== undefined) {
|
|
316
319
|
const rpm = coerceRpm(body!.rpm);
|
|
@@ -385,7 +388,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
385
388
|
// unrouted source can be checked too — and it takes no routing side-effects
|
|
386
389
|
// (no logs, no circuit, no pacing). ?model= is the upstream name sent
|
|
387
390
|
// verbatim; ?format= (repeatable) narrows the protocols, defaulting to every
|
|
388
|
-
// one the source supports
|
|
391
|
+
// one the source supports.
|
|
389
392
|
app.post("/providers/:id/test", async (c) => {
|
|
390
393
|
const p = store.get().providers.find((x) => x.id === c.req.param("id"));
|
|
391
394
|
if (!p) return c.json({ error: { message: "provider not found" } }, 404);
|
|
@@ -394,12 +397,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
394
397
|
const wanted = (c.req.queries("format") ?? []).filter(
|
|
395
398
|
(f): f is RouteKey => f === "openai" || f === "anthropic" || f === "responses",
|
|
396
399
|
);
|
|
397
|
-
const formats: RouteKey[] = wanted.length
|
|
398
|
-
? wanted
|
|
399
|
-
: [...p.formats, ...(p.supportsResponses ? ["responses" as const] : [])];
|
|
400
|
+
const formats: RouteKey[] = wanted.length ? wanted : [...p.formats];
|
|
400
401
|
const results = await Promise.all(
|
|
401
402
|
formats.map(async (format) => {
|
|
402
|
-
if (
|
|
403
|
+
if (!p.formats.includes(format)) {
|
|
403
404
|
return { format, ok: false, status: 0, ms: 0, error: "source does not speak this format" };
|
|
404
405
|
}
|
|
405
406
|
const start = Date.now();
|
|
@@ -513,8 +514,8 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
|
|
|
513
514
|
// chains, save once" flow the web editor is built around. Each format key
|
|
514
515
|
// present in the body REPLACES that FormatEntry wholesale; keys absent from
|
|
515
516
|
// the body are left untouched (a fresh entry seeds them disabled + empty).
|
|
516
|
-
// Every slot's provider must exist and speak that slot's format
|
|
517
|
-
//
|
|
517
|
+
// Every slot's provider must exist and speak that slot's format, mirroring
|
|
518
|
+
// the granular endpoints.
|
|
518
519
|
// The response carries the projected model (GET /models shape) so clients can
|
|
519
520
|
// update in place without a refetch.
|
|
520
521
|
app.put("/models/:name", async (c) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono, type Context, type MiddlewareHandler } from "hono";
|
|
2
2
|
import { trimBase } from "../shared/config";
|
|
3
|
-
import type { DebugCapture, Format,
|
|
3
|
+
import type { DebugCapture, Format, Provider, RouteKey, Usage } from "../shared/types";
|
|
4
4
|
import { CAPTURE_BODY_MAX, type Store } from "./store";
|
|
5
5
|
import { UsageCollector } from "./tokens";
|
|
6
6
|
|
|
@@ -108,6 +108,14 @@ interface CandidateSlot {
|
|
|
108
108
|
sampling?: Record<string, unknown>;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Which Provider base URL serves each routing format (all non-empty for a
|
|
112
|
+
* format the source actually offers — admin validation enforces it). */
|
|
113
|
+
const FORMAT_BASE: Record<RouteKey, "baseUrlOpenai" | "baseUrlAnthropic" | "baseUrlResponses"> = {
|
|
114
|
+
openai: "baseUrlOpenai",
|
|
115
|
+
anthropic: "baseUrlAnthropic",
|
|
116
|
+
responses: "baseUrlResponses",
|
|
117
|
+
};
|
|
118
|
+
|
|
111
119
|
/** Resolve the ordered, compatible provider slots for a model on a routing slot. */
|
|
112
120
|
function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[] {
|
|
113
121
|
const d = store.get();
|
|
@@ -115,9 +123,9 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
|
|
|
115
123
|
const fe = entry?.[key];
|
|
116
124
|
if (!fe?.enabled) return [];
|
|
117
125
|
const byId = new Map(d.providers.map((p) => [p.id, p]));
|
|
118
|
-
// Defense-in-depth:
|
|
119
|
-
//
|
|
120
|
-
//
|
|
126
|
+
// Defense-in-depth: a slot's source must still carry that format AND a base
|
|
127
|
+
// URL for it. (Admin keeps chains pure, but a provider's formats/URLs can be
|
|
128
|
+
// edited afterwards — a source that dropped responses must stop serving it.)
|
|
121
129
|
return fe.providers
|
|
122
130
|
.map((s): CandidateSlot | null => {
|
|
123
131
|
const p = byId.get(s.id);
|
|
@@ -125,7 +133,7 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
|
|
|
125
133
|
})
|
|
126
134
|
.filter((slot): slot is CandidateSlot => {
|
|
127
135
|
if (!slot) return false;
|
|
128
|
-
return
|
|
136
|
+
return slot.provider.formats.includes(key) && !!slot.provider[FORMAT_BASE[key]];
|
|
129
137
|
});
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -311,16 +319,17 @@ export function upstreamHeaders(provider: Provider, format: Format, clientVersio
|
|
|
311
319
|
return h;
|
|
312
320
|
}
|
|
313
321
|
|
|
314
|
-
/** Resolve the upstream URL + wire format for a routing slot.
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* Exported for the admin source-test (direct upstream ping
|
|
322
|
+
/** Resolve the upstream URL + wire format for a routing slot. Each format has
|
|
323
|
+
* its own base URL: the OpenAI and Responses bases include the version segment
|
|
324
|
+
* (we append the bare resource); the Anthropic base excludes /v1 (we append
|
|
325
|
+
* v1/messages). Exported for the admin source-test (direct upstream ping). */
|
|
318
326
|
export function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format } {
|
|
319
327
|
if (key === "anthropic") {
|
|
320
328
|
return { url: `${trimBase(p.baseUrlAnthropic)}/v1/messages`, wire: "anthropic" };
|
|
321
329
|
}
|
|
330
|
+
const base = key === "responses" ? p.baseUrlResponses : p.baseUrlOpenai;
|
|
322
331
|
const path = key === "responses" ? "responses" : "chat/completions";
|
|
323
|
-
return { url: `${trimBase(
|
|
332
|
+
return { url: `${trimBase(base)}/${path}`, wire: "openai" };
|
|
324
333
|
}
|
|
325
334
|
|
|
326
335
|
/** HTTP header values are ByteStrings (Latin-1, code points ≤ 255) — a value
|
|
@@ -499,8 +508,7 @@ function observedBody(
|
|
|
499
508
|
* first_id, last_id, has_more}`. We can't know real created_at / capabilities,
|
|
500
509
|
* so the anthropic shape carries only the honest minimal fields rather than
|
|
501
510
|
* fabricating them. Each surface lists exactly what dispatch would route on
|
|
502
|
-
* it: the entry's chain for that RouteKey must be enabled
|
|
503
|
-
* responses, keep at least one slot on a supportsResponses source). */
|
|
511
|
+
* it: the entry's chain for that RouteKey must be enabled. */
|
|
504
512
|
function modelsList(c: Context, store: Store, key: RouteKey) {
|
|
505
513
|
const d = store.get();
|
|
506
514
|
const byId = new Map(d.providers.map((p) => [p.id, p]));
|
|
@@ -509,19 +517,12 @@ function modelsList(c: Context, store: Store, key: RouteKey) {
|
|
|
509
517
|
const data = enabled.map(([id]) => ({ id, display_name: id, created_at: "1970-01-01T00:00:00Z", type: "model" }));
|
|
510
518
|
return c.json({ data, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, has_more: false });
|
|
511
519
|
}
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
.filter(([, e]) => e[key] && routable(e[key]))
|
|
519
|
-
.map(([id, e]) => ({
|
|
520
|
-
id,
|
|
521
|
-
object: "model",
|
|
522
|
-
created: 0,
|
|
523
|
-
owned_by: byId.get(firstSlot(e[key])?.id ?? "")?.name || "MyAPIKey",
|
|
524
|
-
}));
|
|
520
|
+
const data = enabled.map(([id, e]) => ({
|
|
521
|
+
id,
|
|
522
|
+
object: "model",
|
|
523
|
+
created: 0,
|
|
524
|
+
owned_by: byId.get(e[key].providers[0]?.id ?? "")?.name || "MyAPIKey",
|
|
525
|
+
}));
|
|
525
526
|
return c.json({ object: "list", data });
|
|
526
527
|
}
|
|
527
528
|
|
|
@@ -583,7 +584,7 @@ export function proxyApi(
|
|
|
583
584
|
// (429/500/…), not a collapsed 502, so the badge shows what really happened.
|
|
584
585
|
const pinIndexRaw = c.req.header("x-myapikey-probe-slot");
|
|
585
586
|
const pinIndex = pinIndexRaw !== "" && Number.isInteger(Number(pinIndexRaw)) ? Number(pinIndexRaw) : null;
|
|
586
|
-
// candidates() already restricts
|
|
587
|
+
// candidates() already restricts each chain to sources still offering that format.
|
|
587
588
|
let list = candidates(store, model, key);
|
|
588
589
|
if (pinIndex != null) {
|
|
589
590
|
// An out-of-range index → empty list → 404, so a bad probe is reported as
|
|
@@ -882,7 +883,7 @@ export function proxyApi(
|
|
|
882
883
|
// One call endpoint per surface (/models is registered above, before the
|
|
883
884
|
// auth middleware, so it stays public).
|
|
884
885
|
chat.post("/chat/completions", (c) => dispatch(c, "openai"));
|
|
885
|
-
// OpenAI Responses API — its own routing slot (sources must
|
|
886
|
+
// OpenAI Responses API — its own routing slot (sources must offer responses).
|
|
886
887
|
responses.post("/responses", (c) => dispatch(c, "responses"));
|
|
887
888
|
|
|
888
889
|
// Anthropic surface: messages.
|
|
@@ -305,7 +305,8 @@ export class Store {
|
|
|
305
305
|
const m1 = migrateModels(raw);
|
|
306
306
|
const m2 = migrateProviders(raw);
|
|
307
307
|
const m3 = migrateFormatEntries(raw);
|
|
308
|
-
|
|
308
|
+
const m4 = migrateProviderFormats(raw);
|
|
309
|
+
if (m1 || m2 || m3 || m4 || !raw.version || raw.version < CONFIG_VERSION) {
|
|
309
310
|
raw.version = CONFIG_VERSION;
|
|
310
311
|
this.persist(raw);
|
|
311
312
|
} else if (raw.version > CONFIG_VERSION) {
|
|
@@ -835,7 +836,7 @@ function hitRate(cacheRead: number, input: number, cacheCreation: number): numbe
|
|
|
835
836
|
function migrateModels(raw: GateConfig): boolean {
|
|
836
837
|
const models = raw.models as Record<string, unknown>;
|
|
837
838
|
if (!models || typeof models !== "object") return false;
|
|
838
|
-
const byId = new Map(raw.providers.map((p) => [p.id, p]));
|
|
839
|
+
const byId = new Map(raw.providers.map((p) => [p.id, p as Provider & { supportsResponses?: boolean }]));
|
|
839
840
|
let changed = false;
|
|
840
841
|
for (const [name, entry] of Object.entries(models)) {
|
|
841
842
|
if (!entry || typeof entry !== "object") continue;
|
|
@@ -927,3 +928,28 @@ function migrateFormatEntries(raw: GateConfig): boolean {
|
|
|
927
928
|
}
|
|
928
929
|
return changed;
|
|
929
930
|
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Provider migration to three parallel formats (v5 → v6).
|
|
934
|
+
* - v5 expressed Responses support as `supportsResponses: boolean` on top of
|
|
935
|
+
* `formats: ("openai"|"anthropic")[]`, with /responses reusing the OpenAI
|
|
936
|
+
* base URL.
|
|
937
|
+
* - v6 makes responses a third peer format with its OWN base URL:
|
|
938
|
+
* `formats` gains "responses" when the flag was set, `baseUrlResponses`
|
|
939
|
+
* starts as a COPY of baseUrlOpenai (preserves existing routing exactly —
|
|
940
|
+
* the user can point it elsewhere afterwards), and the flag is deleted.
|
|
941
|
+
* A responses-only backend is now expressible (empty openai base).
|
|
942
|
+
* Idempotent: v6 providers (string `baseUrlResponses`) are skipped. Runs LAST
|
|
943
|
+
* so the older migrations can still read `supportsResponses`/`formats`.
|
|
944
|
+
*/
|
|
945
|
+
function migrateProviderFormats(raw: GateConfig): boolean {
|
|
946
|
+
let changed = false;
|
|
947
|
+
for (const p of raw.providers as (Provider & { supportsResponses?: boolean })[]) {
|
|
948
|
+
if (typeof p.baseUrlResponses === "string") continue; // already v6
|
|
949
|
+
p.baseUrlResponses = p.supportsResponses ? p.baseUrlOpenai : "";
|
|
950
|
+
if (p.supportsResponses && !p.formats.includes("responses")) p.formats.push("responses");
|
|
951
|
+
delete p.supportsResponses;
|
|
952
|
+
changed = true;
|
|
953
|
+
}
|
|
954
|
+
return changed;
|
|
955
|
+
}
|
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { GateConfig } from "./types";
|
|
5
5
|
|
|
6
|
-
export const CONFIG_VERSION =
|
|
6
|
+
export const CONFIG_VERSION = 6;
|
|
7
7
|
export const DEFAULT_PORT = 7800;
|
|
8
8
|
/** Default on-disk home for the gateway's data: data.json + logs.jsonl live here. */
|
|
9
9
|
export const DEFAULT_DATA_DIR = join(homedir(), ".myapikey");
|
|
@@ -11,14 +11,19 @@ export interface Provider {
|
|
|
11
11
|
id: string; // prv_<rand>
|
|
12
12
|
name: string; // human label, also used as CLI handle
|
|
13
13
|
/** OpenAI-family base INCLUDING the version segment, e.g. https://api.openai.com/v1
|
|
14
|
-
* or Ark's /api/v3. Used for /chat/completions
|
|
14
|
+
* or Ark's /api/v3. Used for /chat/completions (and /models discovery). */
|
|
15
15
|
baseUrlOpenai: string;
|
|
16
16
|
/** Anthropic base EXCLUDING /v1, e.g. https://api.anthropic.com or Ark's /api/coding.
|
|
17
17
|
* The gateway appends /v1/messages (and /v1/models for discovery). */
|
|
18
18
|
baseUrlAnthropic: string;
|
|
19
|
+
/** OpenAI-family base for the Responses API, INCLUDING the version segment.
|
|
20
|
+
* Independent of baseUrlOpenai — a backend may serve /responses at a
|
|
21
|
+
* different address (or serve ONLY responses). The gateway appends
|
|
22
|
+
* /responses (and /models for discovery). Empty = responses not served. */
|
|
23
|
+
baseUrlResponses: string;
|
|
19
24
|
apiKey: string;
|
|
20
|
-
/** Which
|
|
21
|
-
formats:
|
|
25
|
+
/** Which routing formats this backend responds to (one per agent surface). */
|
|
26
|
+
formats: RouteKey[];
|
|
22
27
|
/** Optional request-per-minute cap (RPM pacing). When set, dispatch skips this
|
|
23
28
|
* source once it has forwarded `rpm` calls in the trailing 60s window — the
|
|
24
29
|
* request fails over to the next source instead of racing the upstream's own
|
|
@@ -26,9 +31,6 @@ export interface Provider {
|
|
|
26
31
|
* The limit is on the key, so it's per-source and shared across every model
|
|
27
32
|
* routed through it. Tracked in-memory only (see Store.rpmUsed). */
|
|
28
33
|
rpm?: number;
|
|
29
|
-
/** Whether this backend also implements the OpenAI Responses API (/responses).
|
|
30
|
-
* NOT implied by `formats` — many openai-compatible backends lack it. Opt-in. */
|
|
31
|
-
supportsResponses?: boolean;
|
|
32
34
|
/** Model ids this provider offered at last discovery (cached, may be stale). */
|
|
33
35
|
discoveredModels?: string[];
|
|
34
36
|
/** Manually supplemented upstream model ids — names a backend's /models list
|
|
@@ -44,9 +46,8 @@ export interface Provider {
|
|
|
44
46
|
/** One routing slot: an independent enable flag + a priority-ordered chain of
|
|
45
47
|
* (provider, optional upstream model) pairs. Invariant (enforced by admin
|
|
46
48
|
* mutations, defended by proxy candidates()): every id in `providers` exists in
|
|
47
|
-
* `GateConfig.providers` and is compatible with the slot —
|
|
48
|
-
*
|
|
49
|
-
* supportsResponses sources (still OpenAI-format).
|
|
49
|
+
* `GateConfig.providers` and is compatible with the slot — it must carry that
|
|
50
|
+
* format in `formats` (with the matching base URL filled in).
|
|
50
51
|
*
|
|
51
52
|
* A provider id may appear MORE THAN ONCE — each occurrence is an independent
|
|
52
53
|
* failover slot that can carry its own upstream model name. When forwarding to
|
|
@@ -92,10 +93,10 @@ export interface ChainSlot {
|
|
|
92
93
|
sampling?: Record<string, unknown>;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/** A model's routing dimensions — one per forwarding endpoint.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
/** A model's routing dimensions — one per forwarding endpoint. The three are
|
|
97
|
+
* fully independent surfaces with independent per-source base URLs: a source
|
|
98
|
+
* can serve any subset (responses-only and chat-only are both legal), and the
|
|
99
|
+
* same source can sit in several chains at once. */
|
|
99
100
|
export type RouteKey = "openai" | "anthropic" | "responses";
|
|
100
101
|
|
|
101
102
|
/** Per-model routing entry, keyed by model name (e.g. "gpt-4o"). */
|