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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/README.zh-CN.md +295 -0
- package/package.json +84 -0
- package/packages/core/src/cli/client.ts +68 -0
- package/packages/core/src/cli/config.ts +64 -0
- package/packages/core/src/cli/index.ts +272 -0
- package/packages/core/src/server/admin.ts +518 -0
- package/packages/core/src/server/app.ts +55 -0
- package/packages/core/src/server/auth.ts +74 -0
- package/packages/core/src/server/proxy.ts +226 -0
- package/packages/core/src/server/store.ts +559 -0
- package/packages/core/src/shared/config.ts +35 -0
- package/packages/core/src/shared/types.ts +107 -0
- package/packages/web/dist/assets/index-5_bJkp0p.css +1 -0
- package/packages/web/dist/assets/index-tDr_JIKI.js +285 -0
- package/packages/web/dist/index.html +24 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defaultConfig, newApiKey, CONFIG_VERSION } from "../shared/config";
|
|
4
|
+
import type { GateConfig, LogEntry, Provider } from "../shared/types";
|
|
5
|
+
|
|
6
|
+
/** Call-log retention: the log is bounded two ways — never older than this, and
|
|
7
|
+
* never more than LOG_MAX_LINES entries. Whichever binds first. 90 days covers
|
|
8
|
+
* usage-trend ranges; the 1M line cap is a safety valve for runaway volume. */
|
|
9
|
+
const LOG_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
10
|
+
const LOG_MAX_LINES = 1_000_000;
|
|
11
|
+
/** Run a full age+count trim at most once per this many NEW lines, so the trim
|
|
12
|
+
* cost (a whole-file rewrite) is amortized instead of paid on every call. The
|
|
13
|
+
* 1M cap is also checked directly so it can't overshoot between checks. */
|
|
14
|
+
const LOG_TRIM_CHECK_EVERY = 5_000;
|
|
15
|
+
/** How many recent lines GET /admin/logs returns (the "recent calls" timeline).
|
|
16
|
+
* The full history lives in the same file for stats — this is just the tail. */
|
|
17
|
+
const LOG_RECENT = 200;
|
|
18
|
+
/** Tail-read window for the recent-calls view: large enough to hold LOG_RECENT
|
|
19
|
+
* lines even with chunky error text, so getLogs() never reads the whole file. */
|
|
20
|
+
const LOG_TAIL_BYTES = 512 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Circuit-breaker backoff: a transient failure cools a provider for BASE ms,
|
|
23
|
+
* doubling each consecutive failure up to CAP. Resets on the next success. */
|
|
24
|
+
const CB_BASE = 30_000;
|
|
25
|
+
const CB_CAP = 300_000;
|
|
26
|
+
|
|
27
|
+
/** Per-provider circuit state (in-memory, never persisted). */
|
|
28
|
+
interface CircuitEntry {
|
|
29
|
+
fails: number;
|
|
30
|
+
/** Epoch ms until which the provider is skipped. 0 = open. */
|
|
31
|
+
until: number;
|
|
32
|
+
lastStatus: number;
|
|
33
|
+
lastReason: string;
|
|
34
|
+
lastTs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read-only circuit view exposed at GET /admin/circuit. */
|
|
38
|
+
export interface CircuitView {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
state: "open" | "cooling";
|
|
42
|
+
fails: number;
|
|
43
|
+
secondsLeft: number;
|
|
44
|
+
until: number;
|
|
45
|
+
lastStatus: number;
|
|
46
|
+
lastReason: string;
|
|
47
|
+
lastTs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One bucket in a stats breakdown (by model / provider / format). `id` is set
|
|
51
|
+
* only on provider buckets (the stable grouping key); `key` is the label shown. */
|
|
52
|
+
export interface StatBucket {
|
|
53
|
+
key: string;
|
|
54
|
+
id?: string;
|
|
55
|
+
calls: number;
|
|
56
|
+
success: number;
|
|
57
|
+
error: number;
|
|
58
|
+
avgMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One day in the stats time series. */
|
|
62
|
+
export interface StatDay {
|
|
63
|
+
/** YYYY-MM-DD (local). */
|
|
64
|
+
day: string;
|
|
65
|
+
calls: number;
|
|
66
|
+
success: number;
|
|
67
|
+
error: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Aggregated call stats for GET /admin/stats. */
|
|
71
|
+
export interface StatsResult {
|
|
72
|
+
from: number;
|
|
73
|
+
to: number;
|
|
74
|
+
totals: {
|
|
75
|
+
calls: number;
|
|
76
|
+
success: number;
|
|
77
|
+
error: number;
|
|
78
|
+
errorRate: number;
|
|
79
|
+
avgMs: number;
|
|
80
|
+
p50Ms: number;
|
|
81
|
+
p95Ms: number;
|
|
82
|
+
};
|
|
83
|
+
byModel: StatBucket[];
|
|
84
|
+
byProvider: StatBucket[];
|
|
85
|
+
byFormat: StatBucket[];
|
|
86
|
+
byDay: StatDay[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Owns a single data directory (default ~/.myapikey): data.json holds the
|
|
91
|
+
* config, logs.jsonl holds recent calls. Reads config into memory at startup,
|
|
92
|
+
* writes through on every mutation. Mutations are serialized via a promise
|
|
93
|
+
* chain so concurrent admin requests can't trample each other.
|
|
94
|
+
*/
|
|
95
|
+
export class Store {
|
|
96
|
+
private data: GateConfig;
|
|
97
|
+
private readonly dataDir: string;
|
|
98
|
+
private readonly dataPath: string;
|
|
99
|
+
private readonly logsPath: string;
|
|
100
|
+
private readonly credentialsPath: string;
|
|
101
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
102
|
+
/** Line count of the on-disk log (drives periodic trimming). The entries
|
|
103
|
+
* themselves are persisted to logs.jsonl, never held in memory. */
|
|
104
|
+
private logCount = 0;
|
|
105
|
+
/** logCount as of the last trim pass — bounds how often pushLog triggers one. */
|
|
106
|
+
private lastTrimCount = 0;
|
|
107
|
+
/** Per-provider circuit-breaker state (transient failures only). In-memory,
|
|
108
|
+
* NOT persisted (resets on restart). Mutated via the circuit* methods only,
|
|
109
|
+
* never through update()/persist(). */
|
|
110
|
+
private circuit = new Map<string, CircuitEntry>();
|
|
111
|
+
|
|
112
|
+
constructor(dataDir: string) {
|
|
113
|
+
this.dataDir = dataDir;
|
|
114
|
+
this.dataPath = join(dataDir, "data.json");
|
|
115
|
+
this.logsPath = join(dataDir, "logs.jsonl");
|
|
116
|
+
this.credentialsPath = join(dataDir, "credentials.txt");
|
|
117
|
+
this.data = this.load();
|
|
118
|
+
this.logCount = this.countLogs();
|
|
119
|
+
// Don't trim on the very first post-startup call: let normal hysteresis do
|
|
120
|
+
// it. (Stale >90-day data still gets cut at the next trim, within a few
|
|
121
|
+
// thousand calls — there's no urgency to cleaning already-old rows.)
|
|
122
|
+
this.lastTrimCount = this.logCount;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolved on-disk locations (for read-only display in Settings). */
|
|
126
|
+
getPaths(): { dataDir: string; dataFile: string; logsFile: string; credentialsFile: string } {
|
|
127
|
+
return {
|
|
128
|
+
dataDir: this.dataDir,
|
|
129
|
+
dataFile: this.dataPath,
|
|
130
|
+
logsFile: this.logsPath,
|
|
131
|
+
credentialsFile: this.credentialsPath,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Write a human-readable credentials.txt (web login + /v1 api key), current
|
|
137
|
+
* as of this boot. So a brand-new user — or anyone who closed the startup
|
|
138
|
+
* terminal / runs serve as a daemon — can still recover the login: just
|
|
139
|
+
* `cat <dataDir>/credentials.txt`. Regenerated on every startup, so it stays
|
|
140
|
+
* correct after a password change + restart. Returns the file path.
|
|
141
|
+
*/
|
|
142
|
+
writeCredentialsFile(): string {
|
|
143
|
+
const { account, apiKey } = this.data;
|
|
144
|
+
const body = [
|
|
145
|
+
"MyAPIKey credentials",
|
|
146
|
+
"====================",
|
|
147
|
+
"",
|
|
148
|
+
"Web UI login:",
|
|
149
|
+
` username: ${account.username}`,
|
|
150
|
+
` password: ${account.password}`,
|
|
151
|
+
"",
|
|
152
|
+
"API key (for agents calling /v1):",
|
|
153
|
+
` ${apiKey}`,
|
|
154
|
+
"",
|
|
155
|
+
"Regenerated on each startup. If you change the password in Settings, this",
|
|
156
|
+
'file updates on the next restart. Safe to delete once you\'ve saved the',
|
|
157
|
+
"credentials elsewhere.",
|
|
158
|
+
"",
|
|
159
|
+
].join("\n");
|
|
160
|
+
mkdirSync(this.dataDir, { recursive: true });
|
|
161
|
+
writeFileSync(this.credentialsPath, body);
|
|
162
|
+
return this.credentialsPath;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private load(): GateConfig {
|
|
166
|
+
if (!existsSync(this.dataPath)) {
|
|
167
|
+
const fresh = defaultConfig();
|
|
168
|
+
this.persist(fresh);
|
|
169
|
+
return fresh;
|
|
170
|
+
}
|
|
171
|
+
const raw = JSON.parse(readFileSync(this.dataPath, "utf8")) as GateConfig;
|
|
172
|
+
// Light sanity check; fall back to defaults if structurally broken.
|
|
173
|
+
if (!raw || typeof raw !== "object" || !raw.account) return defaultConfig();
|
|
174
|
+
raw.providers ??= [];
|
|
175
|
+
raw.models ??= {};
|
|
176
|
+
// Migration: older configs had no separate API key (the account password
|
|
177
|
+
// doubled as one). Generate one and persist immediately so it's stable
|
|
178
|
+
// across restarts (not regenerated on every boot until the next change).
|
|
179
|
+
if (!raw.apiKey) {
|
|
180
|
+
raw.apiKey = newApiKey();
|
|
181
|
+
this.persist(raw);
|
|
182
|
+
}
|
|
183
|
+
// Migration: model entries were { enabled, providers[] } (v1), then
|
|
184
|
+
// { openai, anthropic } (v2). Split into three routing slots
|
|
185
|
+
// { openai, anthropic, responses } so /responses routes independently.
|
|
186
|
+
// Idempotent; persisted immediately so the upgrade is stable across restarts.
|
|
187
|
+
if (migrateModels(raw) || migrateProviders(raw) || !raw.version || raw.version < CONFIG_VERSION) {
|
|
188
|
+
raw.version = CONFIG_VERSION;
|
|
189
|
+
this.persist(raw);
|
|
190
|
+
} else if (raw.version > CONFIG_VERSION) {
|
|
191
|
+
console.warn(
|
|
192
|
+
`myapikey: data.json version ${raw.version} is newer than supported ${CONFIG_VERSION}; continuing best-effort.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private persist(d: GateConfig): void {
|
|
199
|
+
mkdirSync(this.dataDir, { recursive: true });
|
|
200
|
+
writeFileSync(this.dataPath, JSON.stringify(d, null, 2));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Read current config (from memory). */
|
|
204
|
+
get(): GateConfig {
|
|
205
|
+
return this.data;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Mutate config; persisted atomically after fn runs. */
|
|
209
|
+
async update(fn: (d: GateConfig) => void): Promise<GateConfig> {
|
|
210
|
+
const run = this.chain.then(() => {
|
|
211
|
+
fn(this.data);
|
|
212
|
+
this.persist(this.data);
|
|
213
|
+
return this.data;
|
|
214
|
+
});
|
|
215
|
+
this.chain = run.then(
|
|
216
|
+
() => undefined,
|
|
217
|
+
() => undefined,
|
|
218
|
+
);
|
|
219
|
+
return run;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Append a call to the on-disk log (one JSON object per line). */
|
|
223
|
+
pushLog(entry: LogEntry): void {
|
|
224
|
+
appendFileSync(this.logsPath, JSON.stringify(entry) + "\n");
|
|
225
|
+
this.logCount++;
|
|
226
|
+
// Amortized trim: a full age+count pass at most once per LOG_TRIM_CHECK_EVERY
|
|
227
|
+
// new lines, plus immediately if the hard line cap is crossed.
|
|
228
|
+
if (this.logCount - this.lastTrimCount >= LOG_TRIM_CHECK_EVERY || this.logCount > LOG_MAX_LINES) {
|
|
229
|
+
this.trimLogs();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Recent log entries, newest first, capped at LOG_RECENT. Reads only the tail
|
|
234
|
+
* of the file (LOG_TAIL_BYTES) so the Logs page's 4s poll stays cheap no
|
|
235
|
+
* matter how large the retained history grows. */
|
|
236
|
+
getLogs(): LogEntry[] {
|
|
237
|
+
if (!existsSync(this.logsPath)) return [];
|
|
238
|
+
const size = statSync(this.logsPath).size;
|
|
239
|
+
const len = Math.min(size, LOG_TAIL_BYTES);
|
|
240
|
+
const fd = openSync(this.logsPath, "r");
|
|
241
|
+
try {
|
|
242
|
+
const buf = Buffer.alloc(len);
|
|
243
|
+
if (len > 0) readSync(fd, buf, 0, len, size - len);
|
|
244
|
+
// If we sliced into the file, the first line is partial — drop it. When we
|
|
245
|
+
// read the whole file the first line is complete.
|
|
246
|
+
const lines = buf.toString("utf8").split("\n");
|
|
247
|
+
const start = len < size ? 1 : 0;
|
|
248
|
+
const entries: LogEntry[] = [];
|
|
249
|
+
for (let i = start; i < lines.length; i++) {
|
|
250
|
+
const s = lines[i].trim();
|
|
251
|
+
if (!s) continue;
|
|
252
|
+
try {
|
|
253
|
+
entries.push(JSON.parse(s) as LogEntry);
|
|
254
|
+
} catch {
|
|
255
|
+
// Partial tail line if the process was interrupted mid-append; skip it.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return entries.slice(-LOG_RECENT).reverse();
|
|
259
|
+
} finally {
|
|
260
|
+
closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Rewrite the log enforcing both bounds: drop entries older than
|
|
265
|
+
* LOG_MAX_AGE_MS, then trim to the most recent LOG_MAX_LINES. Malformed lines
|
|
266
|
+
* (a partial tail from an interrupted append) are dropped here too. */
|
|
267
|
+
private trimLogs(): void {
|
|
268
|
+
if (!existsSync(this.logsPath)) {
|
|
269
|
+
this.logCount = 0;
|
|
270
|
+
this.lastTrimCount = 0;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const lines = readFileSync(this.logsPath, "utf8").split("\n").filter(Boolean);
|
|
274
|
+
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
|
275
|
+
const kept: string[] = [];
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
let ts = 0;
|
|
278
|
+
try {
|
|
279
|
+
ts = (JSON.parse(line) as { ts?: number }).ts ?? 0;
|
|
280
|
+
} catch {
|
|
281
|
+
continue; // drop a malformed (partial-tail) line
|
|
282
|
+
}
|
|
283
|
+
if (ts >= cutoff) kept.push(line);
|
|
284
|
+
}
|
|
285
|
+
// Hard cap on total lines: keep only the most recent LOG_MAX_LINES.
|
|
286
|
+
const final = kept.length > LOG_MAX_LINES ? kept.slice(kept.length - LOG_MAX_LINES) : kept;
|
|
287
|
+
writeFileSync(this.logsPath, final.length ? final.map((l) => l + "\n").join("") : "");
|
|
288
|
+
this.logCount = final.length;
|
|
289
|
+
this.lastTrimCount = this.logCount;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private countLogs(): number {
|
|
293
|
+
if (!existsSync(this.logsPath)) return 0;
|
|
294
|
+
return readFileSync(this.logsPath, "utf8").split("\n").filter(Boolean).length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Aggregate the retained call history into stats for GET /admin/stats. Reads
|
|
298
|
+
* the whole log (acceptable on a stats page load — it is never polled),
|
|
299
|
+
* filters to the given range (rangeMs = 0 means "all retained"), and excludes
|
|
300
|
+
* cooldown rows. Provider breakdown groups by the stable provider id and is
|
|
301
|
+
* labeled with the live name, so renaming a source doesn't split history. */
|
|
302
|
+
getStats(rangeMs: number): StatsResult {
|
|
303
|
+
const to = Date.now();
|
|
304
|
+
const from = rangeMs > 0 ? to - rangeMs : 0;
|
|
305
|
+
const empty: StatsResult = {
|
|
306
|
+
from,
|
|
307
|
+
to,
|
|
308
|
+
totals: { calls: 0, success: 0, error: 0, errorRate: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 },
|
|
309
|
+
byModel: [],
|
|
310
|
+
byProvider: [],
|
|
311
|
+
byFormat: [],
|
|
312
|
+
byDay: [],
|
|
313
|
+
};
|
|
314
|
+
if (!existsSync(this.logsPath)) return empty;
|
|
315
|
+
|
|
316
|
+
const providerName = new Map(this.data.providers.map((p) => [p.id, p.name]));
|
|
317
|
+
const model = new Map<string, Acc>();
|
|
318
|
+
const provider = new Map<string, Acc>();
|
|
319
|
+
const format = new Map<string, Acc>();
|
|
320
|
+
const day = new Map<string, Acc>();
|
|
321
|
+
const tot = newAcc();
|
|
322
|
+
const latencies: number[] = [];
|
|
323
|
+
|
|
324
|
+
for (const line of readFileSync(this.logsPath, "utf8").split("\n")) {
|
|
325
|
+
const s = line.trim();
|
|
326
|
+
if (!s) continue;
|
|
327
|
+
let e: LogEntry;
|
|
328
|
+
try {
|
|
329
|
+
e = JSON.parse(s) as LogEntry;
|
|
330
|
+
} catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (e.kind === "cooldown") continue; // circuit-breaker event, not a call
|
|
334
|
+
if (!e.ts || e.ts < from) continue;
|
|
335
|
+
if (rangeMs > 0 && e.ts > to + 60_000) continue; // future (clock skew) — ignore
|
|
336
|
+
const ok = e.status >= 200 && e.status < 300;
|
|
337
|
+
const err = e.status >= 400;
|
|
338
|
+
const ms = e.ms || 0;
|
|
339
|
+
bump(tot, ok, err, ms);
|
|
340
|
+
latencies.push(ms);
|
|
341
|
+
bump(acc(model, e.model), ok, err, ms);
|
|
342
|
+
bump(acc(provider, e.providerId ?? e.provider ?? "?"), ok, err, ms);
|
|
343
|
+
bump(acc(format, e.format ?? "?"), ok, err, ms);
|
|
344
|
+
bump(acc(day, dayKey(e.ts)), ok, err, ms);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
latencies.sort((a, b) => a - b);
|
|
348
|
+
const pick = (frac: number): number =>
|
|
349
|
+
latencies.length ? latencies[Math.min(latencies.length - 1, Math.floor(frac * latencies.length))] : 0;
|
|
350
|
+
|
|
351
|
+
// Fill every day in [startDay, today] so the chart x-axis is continuous
|
|
352
|
+
// (zero-call days still appear). For "all", start at the earliest data day.
|
|
353
|
+
const todayKey = dayKey(to);
|
|
354
|
+
let startKey: string;
|
|
355
|
+
if (rangeMs > 0) startKey = dayKey(from);
|
|
356
|
+
else if (day.size) startKey = [...day.keys()].sort()[0];
|
|
357
|
+
else startKey = todayKey;
|
|
358
|
+
const byDay: StatDay[] = [];
|
|
359
|
+
const [sy, sm, sd] = startKey.split("-").map(Number);
|
|
360
|
+
// setDate/getDate iterate in local calendar days (DST-safe).
|
|
361
|
+
for (let d = new Date(sy, (sm || 1) - 1, sd || 1); d.getTime() <= to; d.setDate(d.getDate() + 1)) {
|
|
362
|
+
const dk = dayKey(d.getTime());
|
|
363
|
+
const a = day.get(dk);
|
|
364
|
+
byDay.push({ day: dk, calls: a?.calls ?? 0, success: a?.success ?? 0, error: a?.error ?? 0 });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const sortDesc = (a: StatBucket, b: StatBucket) => b.calls - a.calls;
|
|
368
|
+
return {
|
|
369
|
+
from,
|
|
370
|
+
to,
|
|
371
|
+
totals: {
|
|
372
|
+
calls: tot.calls,
|
|
373
|
+
success: tot.success,
|
|
374
|
+
error: tot.error,
|
|
375
|
+
errorRate: tot.calls ? tot.error / tot.calls : 0,
|
|
376
|
+
avgMs: tot.calls ? Math.round(tot.sumMs / tot.calls) : 0,
|
|
377
|
+
p50Ms: pick(0.5),
|
|
378
|
+
p95Ms: pick(0.95),
|
|
379
|
+
},
|
|
380
|
+
byModel: [...model].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
|
|
381
|
+
byProvider: [...provider]
|
|
382
|
+
.map(([k, a]) => {
|
|
383
|
+
const isId = !!k && k !== "?" && this.data.providers.some((p) => p.id === k);
|
|
384
|
+
return { key: isId ? providerName.get(k) ?? k : k, id: isId ? k : undefined, ...fields(a) };
|
|
385
|
+
})
|
|
386
|
+
.sort(sortDesc),
|
|
387
|
+
byFormat: [...format].map(([k, a]) => ({ key: k, ...fields(a) })).sort(sortDesc),
|
|
388
|
+
byDay,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- circuit breaker (transient failures only; in-memory) ---
|
|
393
|
+
|
|
394
|
+
/** Whether a provider is currently in cooldown and should be skipped. */
|
|
395
|
+
isCooling(id: string): boolean {
|
|
396
|
+
const c = this.circuit.get(id);
|
|
397
|
+
return !!c && c.until > Date.now();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Record a transient upstream failure. Escalates cooldown with each
|
|
401
|
+
* consecutive failure (BASE * 2^(fails-1), capped at CAP); `fails` persists
|
|
402
|
+
* across cooldown expirations and is reset only by success — unless the
|
|
403
|
+
* provider has been quiet for > CAP, in which case it starts fresh at 1.
|
|
404
|
+
* Returns `entered` = transitioned from healthy → cooling this call (the
|
|
405
|
+
* caller logs a cooldown row only then, to avoid timeline spam), plus the
|
|
406
|
+
* fails count and cooldown duration for that row. */
|
|
407
|
+
recordCircuitFailure(id: string, status: number, reason: string): { entered: boolean; fails: number; cooldownMs: number } {
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
const cur = this.circuit.get(id);
|
|
410
|
+
const stale = !cur || now - cur.lastTs > CB_CAP;
|
|
411
|
+
const fails = stale ? 1 : cur!.fails + 1;
|
|
412
|
+
const cooldownMs = Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
|
|
413
|
+
const until = now + cooldownMs;
|
|
414
|
+
const wasCooling = !!cur && cur.until > now;
|
|
415
|
+
this.circuit.set(id, { fails, until, lastStatus: status, lastReason: reason, lastTs: now });
|
|
416
|
+
return { entered: !wasCooling, fails, cooldownMs };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A successful call resets the cooldown (circuit closes). Keeps lastTs/
|
|
420
|
+
* lastReason as history; the provider reads as state "open". */
|
|
421
|
+
recordCircuitSuccess(id: string): void {
|
|
422
|
+
const cur = this.circuit.get(id);
|
|
423
|
+
if (!cur || (cur.fails === 0 && cur.until === 0)) return;
|
|
424
|
+
this.circuit.set(id, { ...cur, fails: 0, until: 0 });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Force-clear a provider's cooldown (the UI "reset" button). */
|
|
428
|
+
resetCircuit(id: string): void {
|
|
429
|
+
const cur = this.circuit.get(id);
|
|
430
|
+
if (!cur) return;
|
|
431
|
+
this.circuit.set(id, { ...cur, fails: 0, until: 0 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Snapshot of every configured provider's circuit state for GET /admin/circuit.
|
|
435
|
+
* Healthy providers appear as state "open"; a provider deleted while cooling
|
|
436
|
+
* simply drops out (we iterate the live config, not the map). */
|
|
437
|
+
circuitState(): CircuitView[] {
|
|
438
|
+
const now = Date.now();
|
|
439
|
+
return this.data.providers.map((p) => {
|
|
440
|
+
const c = this.circuit.get(p.id);
|
|
441
|
+
const cooling = !!c && c.until > now;
|
|
442
|
+
return {
|
|
443
|
+
id: p.id,
|
|
444
|
+
name: p.name,
|
|
445
|
+
state: cooling ? "cooling" : "open",
|
|
446
|
+
fails: c?.fails ?? 0,
|
|
447
|
+
secondsLeft: cooling ? Math.max(0, Math.ceil((c!.until - now) / 1000)) : 0,
|
|
448
|
+
until: c?.until ?? 0,
|
|
449
|
+
lastStatus: c?.lastStatus ?? 0,
|
|
450
|
+
lastReason: c?.lastReason ?? "",
|
|
451
|
+
lastTs: c?.lastTs ?? 0,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Running accumulator for a stats bucket. */
|
|
458
|
+
interface Acc {
|
|
459
|
+
calls: number;
|
|
460
|
+
success: number;
|
|
461
|
+
error: number;
|
|
462
|
+
sumMs: number;
|
|
463
|
+
}
|
|
464
|
+
function newAcc(): Acc {
|
|
465
|
+
return { calls: 0, success: 0, error: 0, sumMs: 0 };
|
|
466
|
+
}
|
|
467
|
+
function bump(a: Acc, ok: boolean, err: boolean, ms: number): void {
|
|
468
|
+
a.calls++;
|
|
469
|
+
if (ok) a.success++;
|
|
470
|
+
if (err) a.error++;
|
|
471
|
+
a.sumMs += ms;
|
|
472
|
+
}
|
|
473
|
+
/** Get-or-create a bucket entry in a stats map. */
|
|
474
|
+
function acc(m: Map<string, Acc>, k: string): Acc {
|
|
475
|
+
let a = m.get(k);
|
|
476
|
+
if (!a) m.set(k, (a = newAcc()));
|
|
477
|
+
return a;
|
|
478
|
+
}
|
|
479
|
+
/** Local-calendar YYYY-MM-DD for a timestamp (stats day bucket key). */
|
|
480
|
+
function dayKey(ts: number): string {
|
|
481
|
+
const d = new Date(ts);
|
|
482
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
483
|
+
}
|
|
484
|
+
function fields(a: Acc): Pick<StatBucket, "calls" | "success" | "error" | "avgMs"> {
|
|
485
|
+
return { calls: a.calls, success: a.success, error: a.error, avgMs: a.calls ? Math.round(a.sumMs / a.calls) : 0 };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Model migration to the three-slot shape { openai, anthropic, responses }.
|
|
490
|
+
* - v1 `{ enabled, providers[] }` → split each id by the formats it speaks,
|
|
491
|
+
* plus a responses slot from supportsResponses sources.
|
|
492
|
+
* - v2 `{ openai, anthropic }` → add a responses slot split from the openai
|
|
493
|
+
* chain's supportsResponses sources (the openai chain keeps them too —
|
|
494
|
+
* /chat/completions uses all OpenAI sources, /responses uses the subset).
|
|
495
|
+
* A slot is enabled only if its chain is non-empty (an enabled-but-empty slot
|
|
496
|
+
* would 404 on that endpoint). Dangling ids (provider since deleted) are
|
|
497
|
+
* dropped. Returns true if any entry was rewritten; v3 entries are skipped, so
|
|
498
|
+
* this is idempotent.
|
|
499
|
+
*/
|
|
500
|
+
function migrateModels(raw: GateConfig): boolean {
|
|
501
|
+
const models = raw.models as Record<string, unknown>;
|
|
502
|
+
if (!models || typeof models !== "object") return false;
|
|
503
|
+
const byId = new Map(raw.providers.map((p) => [p.id, p]));
|
|
504
|
+
let changed = false;
|
|
505
|
+
for (const [name, entry] of Object.entries(models)) {
|
|
506
|
+
if (!entry || typeof entry !== "object") continue;
|
|
507
|
+
if ("responses" in entry) continue; // already v3
|
|
508
|
+
|
|
509
|
+
if ("openai" in entry && "anthropic" in entry) {
|
|
510
|
+
// v2 (two slots) → v3: responses split out of the openai chain.
|
|
511
|
+
const e = entry as { openai: { enabled: boolean; providers: string[] } };
|
|
512
|
+
const responses = e.openai.providers.filter((pid) => byId.get(pid)?.supportsResponses);
|
|
513
|
+
(entry as unknown as { responses: { enabled: boolean; providers: string[] } }).responses = {
|
|
514
|
+
enabled: e.openai.enabled && responses.length > 0,
|
|
515
|
+
providers: responses,
|
|
516
|
+
};
|
|
517
|
+
changed = true;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// v1 { enabled, providers[] } → v3 (three slots).
|
|
522
|
+
const e = entry as { enabled?: boolean; providers?: string[] };
|
|
523
|
+
const oldEnabled = !!e.enabled;
|
|
524
|
+
const oldChain = Array.isArray(e.providers) ? e.providers : [];
|
|
525
|
+
const openai = oldChain.filter((pid) => byId.get(pid)?.formats.includes("openai"));
|
|
526
|
+
const anthropic = oldChain.filter((pid) => byId.get(pid)?.formats.includes("anthropic"));
|
|
527
|
+
const responses = oldChain.filter((pid) => byId.get(pid)?.supportsResponses);
|
|
528
|
+
models[name] = {
|
|
529
|
+
openai: { enabled: oldEnabled && openai.length > 0, providers: openai },
|
|
530
|
+
anthropic: { enabled: oldEnabled && anthropic.length > 0, providers: anthropic },
|
|
531
|
+
responses: { enabled: oldEnabled && responses.length > 0, providers: responses },
|
|
532
|
+
};
|
|
533
|
+
changed = true;
|
|
534
|
+
}
|
|
535
|
+
return changed;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Provider migration to per-format base URLs (v3 → v4).
|
|
540
|
+
* - v3 had a single `baseUrl` (incl. the version segment) shared by both formats.
|
|
541
|
+
* - v4 splits it: the OpenAI base keeps the version; the Anthropic base drops the
|
|
542
|
+
* trailing /vN (the gateway now appends v1/messages), reconstructing the
|
|
543
|
+
* documented Anthropic base (which never includes /v1).
|
|
544
|
+
* Idempotent: v4 providers (no string `baseUrl`) are skipped. Returns true if any
|
|
545
|
+
* provider was rewritten.
|
|
546
|
+
*/
|
|
547
|
+
function migrateProviders(raw: GateConfig): boolean {
|
|
548
|
+
let changed = false;
|
|
549
|
+
for (const p of raw.providers as (Provider & { baseUrl?: string })[]) {
|
|
550
|
+
if (typeof p.baseUrl !== "string") continue; // already v4
|
|
551
|
+
const old = p.baseUrl;
|
|
552
|
+
p.baseUrlOpenai = old;
|
|
553
|
+
const stripped = old.replace(/\/v\d+$/, "");
|
|
554
|
+
p.baseUrlAnthropic = stripped || old;
|
|
555
|
+
delete p.baseUrl;
|
|
556
|
+
changed = true;
|
|
557
|
+
}
|
|
558
|
+
return changed;
|
|
559
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { GateConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_VERSION = 4;
|
|
7
|
+
export const DEFAULT_PORT = 7800;
|
|
8
|
+
/** Default on-disk home for the gateway's data: data.json + logs.jsonl live here. */
|
|
9
|
+
export const DEFAULT_DATA_DIR = join(homedir(), ".myapikey");
|
|
10
|
+
|
|
11
|
+
/** A fresh config with a randomly generated single account/password + API key. */
|
|
12
|
+
export function defaultConfig(): GateConfig {
|
|
13
|
+
return {
|
|
14
|
+
version: CONFIG_VERSION,
|
|
15
|
+
account: { username: "admin", password: randomBytes(18).toString("base64url") },
|
|
16
|
+
apiKey: newApiKey(),
|
|
17
|
+
providers: [],
|
|
18
|
+
models: {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Generate an API key (sk-myapikey-…) used by agents to call /v1. */
|
|
23
|
+
export function newApiKey(): string {
|
|
24
|
+
return "sk-myapikey-" + randomBytes(24).toString("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Generate a provider id. */
|
|
28
|
+
export function newProviderId(): string {
|
|
29
|
+
return "prv_" + randomBytes(8).toString("base64url");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strip trailing slashes from a base url. */
|
|
33
|
+
export function trimBase(url: string): string {
|
|
34
|
+
return url.replace(/\/+$/, "");
|
|
35
|
+
}
|