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/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "myapikey",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "keywords": [
7
+ "llm",
8
+ "llm-gateway",
9
+ "ai-gateway",
10
+ "api-gateway",
11
+ "gateway",
12
+ "proxy",
13
+ "llm-proxy",
14
+ "ai-proxy",
15
+ "llm-router",
16
+ "model-router",
17
+ "openai",
18
+ "anthropic",
19
+ "claude",
20
+ "claude-code",
21
+ "openai-compatible",
22
+ "chat-completions",
23
+ "passthrough",
24
+ "failover",
25
+ "circuit-breaker",
26
+ "multi-provider",
27
+ "self-hosted",
28
+ "homelab",
29
+ "cli",
30
+ "hono",
31
+ "typescript",
32
+ "vue"
33
+ ],
34
+ "homepage": "https://github.com/vat-wiki/myapikey#readme",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/vat-wiki/myapikey.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/vat-wiki/myapikey/issues"
41
+ },
42
+ "license": "MIT",
43
+ "author": "vat-wiki",
44
+ "bin": {
45
+ "myapikey": "packages/core/src/cli/index.ts"
46
+ },
47
+ "files": [
48
+ "packages/core/src",
49
+ "packages/web/dist"
50
+ ],
51
+ "workspaces": [
52
+ "packages/*"
53
+ ],
54
+ "scripts": {
55
+ "dev": "tsx watch packages/core/src/cli/index.ts serve",
56
+ "dev:web": "npm run dev -w @myapikey/web",
57
+ "start": "tsx packages/core/src/cli/index.ts serve",
58
+ "typecheck": "tsc -p packages/core/tsconfig.json --noEmit && tsc -p packages/core/tsconfig.test.json --noEmit && vue-tsc --noEmit -p packages/web/tsconfig.json",
59
+ "build:web": "npm run build -w @myapikey/web",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "test:coverage": "vitest run --coverage",
63
+ "test:e2e": "playwright test"
64
+ },
65
+ "engines": {
66
+ "node": ">=18"
67
+ },
68
+ "dependencies": {
69
+ "@hono/node-server": "^1.13.5",
70
+ "@hono/zod-validator": "^0.4.2",
71
+ "commander": "^12.1.0",
72
+ "hono": "^4.6.12",
73
+ "tsx": "^4.19.0",
74
+ "zod": "^3.23.8"
75
+ },
76
+ "devDependencies": {
77
+ "@playwright/test": "^1.62.1",
78
+ "@types/node": "^22.7.0",
79
+ "@vitest/coverage-v8": "^2.1.9",
80
+ "jsdom": "^30.0.1",
81
+ "typescript": "^5.6.0",
82
+ "vitest": "^2.1.9"
83
+ }
84
+ }
@@ -0,0 +1,68 @@
1
+ import { resolveApiKey, resolveCreds, resolveUrl } from "./config";
2
+
3
+ export class ApiError extends Error {
4
+ constructor(public status: number, message: string) {
5
+ super(message);
6
+ }
7
+ }
8
+
9
+ export interface Ctx {
10
+ url: string;
11
+ auth: string; // Basic header value ("" if no account creds) — for /admin
12
+ apiKey?: string; // Bearer token for /v1
13
+ }
14
+
15
+ interface Opts {
16
+ url?: string;
17
+ user?: string;
18
+ pass?: string;
19
+ apiKey?: string;
20
+ }
21
+
22
+ /** Build a request context from flags → env → saved profile. */
23
+ export function makeCtx(opts: Opts = {}): Ctx {
24
+ const url = resolveUrl(opts.url).replace(/\/+$/, "");
25
+ const creds = resolveCreds(opts.user, opts.pass);
26
+ const apiKey = resolveApiKey(opts.apiKey);
27
+ const auth = creds ? "Basic " + Buffer.from(`${creds.username}:${creds.password}`).toString("base64") : "";
28
+ return { url, auth, apiKey };
29
+ }
30
+
31
+ export async function api<T = unknown>(
32
+ ctx: Ctx,
33
+ method: string,
34
+ path: string,
35
+ body?: unknown,
36
+ ): Promise<T> {
37
+ const isV1 = path.startsWith("/v1");
38
+ // /v1 takes the API key (Bearer); everything else (/admin) takes account Basic.
39
+ if (isV1 && !ctx.apiKey) {
40
+ throw new Error("No API key for /v1. Run `myapikey serve`, set MYAPIKEY_API_KEY, or pass --api-key.");
41
+ }
42
+ if (!isV1 && !ctx.auth) {
43
+ throw new Error("No account credentials for /admin. Run `myapikey serve`, set MYAPIKEY_USER/MYAPIKEY_PASS, or pass --user/--pass.");
44
+ }
45
+ const res = await fetch(`${ctx.url}${path}`, {
46
+ method,
47
+ headers: {
48
+ authorization: isV1 ? `Bearer ${ctx.apiKey}` : ctx.auth,
49
+ ...(body ? { "content-type": "application/json" } : {}),
50
+ },
51
+ body: body ? JSON.stringify(body) : undefined,
52
+ });
53
+ const text = await res.text();
54
+ let json: unknown = null;
55
+ try {
56
+ json = text ? JSON.parse(text) : null;
57
+ } catch {
58
+ /* keep as text */
59
+ }
60
+ if (!res.ok) {
61
+ const msg =
62
+ (json as { error?: { message?: string } } | null)?.error?.message ??
63
+ text ??
64
+ res.statusText;
65
+ throw new ApiError(res.status, String(msg));
66
+ }
67
+ return json as T;
68
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { DEFAULT_DATA_DIR } from "../shared/config";
5
+
6
+ export interface CliProfile {
7
+ url: string;
8
+ username: string;
9
+ password: string;
10
+ apiKey: string;
11
+ }
12
+
13
+ /**
14
+ * Where the CLI client profile lives. Pinned to the default app home
15
+ * (~/.myapikey) regardless of the server's --data-dir override — client commands
16
+ * (whoami / provider / model / call) need a stable, discoverable path and never
17
+ * see --data-dir. The legacy ~/.config/myapikey/config.json is still read as a
18
+ * fallback so existing setups keep working after upgrade.
19
+ */
20
+ export const CLIENT_PROFILE_PATH = join(DEFAULT_DATA_DIR, "client.json");
21
+ const LEGACY_PROFILE_PATH = join(homedir(), ".config", "myapikey", "config.json");
22
+
23
+ function resolveProfilePath(): string {
24
+ if (existsSync(CLIENT_PROFILE_PATH)) return CLIENT_PROFILE_PATH;
25
+ if (existsSync(LEGACY_PROFILE_PATH)) return LEGACY_PROFILE_PATH;
26
+ return CLIENT_PROFILE_PATH;
27
+ }
28
+
29
+ export function loadProfile(): CliProfile | null {
30
+ const path = resolveProfilePath();
31
+ if (!existsSync(path)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf8")) as CliProfile;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function saveProfile(p: CliProfile): void {
40
+ mkdirSync(dirname(CLIENT_PROFILE_PATH), { recursive: true });
41
+ writeFileSync(CLIENT_PROFILE_PATH, JSON.stringify(p, null, 2));
42
+ }
43
+
44
+ export function resolveUrl(flag?: string): string {
45
+ if (flag) return flag;
46
+ if (process.env.MYAPIKEY_URL) return process.env.MYAPIKEY_URL;
47
+ return loadProfile()?.url ?? "http://localhost:7800";
48
+ }
49
+
50
+ export function resolveCreds(
51
+ flagUser?: string,
52
+ flagPass?: string,
53
+ ): { username: string; password: string } | null {
54
+ const profile = loadProfile();
55
+ const user = flagUser ?? process.env.MYAPIKEY_USER ?? profile?.username;
56
+ const pass = flagPass ?? process.env.MYAPIKEY_PASS ?? profile?.password;
57
+ if (!user || !pass) return null;
58
+ return { username: user, password: pass };
59
+ }
60
+
61
+ /** Resolve the API key used to call /v1: flag → env → saved profile. */
62
+ export function resolveApiKey(flag?: string): string | undefined {
63
+ return flag ?? process.env.MYAPIKEY_API_KEY ?? loadProfile()?.apiKey;
64
+ }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env tsx
2
+ import { Command, Option } from "commander";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve, join } from "node:path";
5
+ import { serve } from "@hono/node-server";
6
+ import { createApp } from "../server/app";
7
+ import { Store } from "../server/store";
8
+ import { DEFAULT_PORT, DEFAULT_DATA_DIR } from "../shared/config";
9
+ import { loadProfile, saveProfile, resolveApiKey, CLIENT_PROFILE_PATH } from "./config";
10
+ import { api, ApiError, makeCtx } from "./client";
11
+
12
+ interface Globals {
13
+ url?: string;
14
+ user?: string;
15
+ pass?: string;
16
+ apiKey?: string;
17
+ }
18
+
19
+ const program = new Command();
20
+ program
21
+ .name("myapikey")
22
+ .description("MyAPIKey — personal LLM API gateway")
23
+ .option("-u, --url <url>", "gateway base URL")
24
+ .option("--user <user>", "account username")
25
+ .option("--pass <pass>", "account password")
26
+ .option("--api-key <key>", "api key for /v1 (agent calls)")
27
+ .hook("preAction", () => undefined);
28
+
29
+ const ctx = (): ReturnType<typeof makeCtx> => makeCtx(program.opts<Globals>());
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // serve
33
+ // ---------------------------------------------------------------------------
34
+ program
35
+ .command("serve")
36
+ .description("run the gateway server")
37
+ .option("-p, --port <n>", "port", String(DEFAULT_PORT))
38
+ .option("--data-dir <dir>", "directory for data.json + logs.jsonl")
39
+ .option("--web-dir <path>", "path to built web dist", resolve(import.meta.dirname, "../../../web/dist"))
40
+ .action(async (opts: { port: string; dataDir?: string; webDir: string }) => {
41
+ const dataDir = resolve(opts.dataDir ?? process.env.MYAPIKEY_DATA_DIR ?? DEFAULT_DATA_DIR);
42
+ const firstRun = !existsSync(join(dataDir, "data.json"));
43
+ const store = new Store(dataDir);
44
+ const credentialsFile = store.writeCredentialsFile();
45
+ const webDir = existsSync(opts.webDir) ? opts.webDir : undefined;
46
+ const app = createApp(store, { webDir });
47
+
48
+ const port = Number(opts.port);
49
+ serve({ fetch: app.fetch, port }, async (info) => {
50
+ const url = `http://localhost:${info.port}`;
51
+ console.log(`\n MyAPIKey listening on ${url}`);
52
+ if (webDir) console.log(` web UI: ${url}`);
53
+ else console.log(` web UI: not built (run: npm run build:web)`);
54
+ console.log(` proxy: ${url}/v1/chat/completions (OpenAI)`);
55
+ console.log(` ${url}/v1/responses (OpenAI Responses)`);
56
+ console.log(` ${url}/v1/messages (Anthropic)`);
57
+ console.log(` data: ${dataDir} (override with --data-dir or MYAPIKEY_DATA_DIR)\n`);
58
+
59
+ if (firstRun) {
60
+ const { account, apiKey } = store.get();
61
+ console.log(" First run — here are your credentials (save them):");
62
+ console.log(` username : ${account.username} (web login)`);
63
+ console.log(` password : ${account.password} (web login)`);
64
+ console.log(` api key : ${apiKey} (put this in the tool's "api key" field)`);
65
+ console.log(` ↳ also written to ${credentialsFile} (cat it anytime if you forget)\n`);
66
+ saveProfile({ url, username: account.username, password: account.password, apiKey });
67
+ console.log(` Saved to ${CLIENT_PROFILE_PATH} for CLI use.`);
68
+ console.log(` Next: myapikey provider add <name> --base-url-openai <url> --key <key> --formats openai,anthropic\n`);
69
+ }
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // whoami
75
+ // ---------------------------------------------------------------------------
76
+ program
77
+ .command("whoami")
78
+ .description("print connection info for wiring up agents")
79
+ .action(() => {
80
+ const profile = loadProfile();
81
+ if (!profile) {
82
+ console.log("No saved profile. Run `myapikey serve` first (or use --url/--api-key).");
83
+ return;
84
+ }
85
+ const apiKey = resolveApiKey() ?? profile.apiKey;
86
+ console.log("Connection info:");
87
+ console.log(` base url : ${profile.url}`);
88
+ if (apiKey) {
89
+ console.log(` api key : ${apiKey} ← put this in the tool's "api key" field`);
90
+ } else {
91
+ console.log(` api key : (not saved — run \`myapikey serve\` on your gateway, or set MYAPIKEY_API_KEY)`);
92
+ }
93
+ console.log(` login : ${profile.username} / ${profile.password} ← only for the web UI\n`);
94
+ if (apiKey) {
95
+ console.log("Example (OpenAI SDK):");
96
+ console.log(` OPENAI_BASE_URL=${profile.url}/v1 OPENAI_API_KEY=${apiKey}`);
97
+ console.log("\nExample (Claude Code / Anthropic):");
98
+ console.log(` ANTHROPIC_BASE_URL=${profile.url} ANTHROPIC_API_KEY=${apiKey}`);
99
+ }
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // provider
104
+ // ---------------------------------------------------------------------------
105
+ const provider = program.command("provider").description("manage backends");
106
+
107
+ provider
108
+ .command("add <name>")
109
+ .option("--base-url-openai <url>", "OpenAI base URL incl. version, e.g. https://api.openai.com/v1", "")
110
+ .option("--base-url-anthropic <url>", "Anthropic base URL excl. /v1, e.g. https://api.anthropic.com", "")
111
+ .option("--key <key>", "api key for the backend", "")
112
+ .option("--formats <list>", "comma list: openai,anthropic", "openai")
113
+ .action(async (name: string, opts: { baseUrlOpenai: string; baseUrlAnthropic: string; key: string; formats: string }) => {
114
+ const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean) as ("openai" | "anthropic")[];
115
+ const r = await api(ctx(), "POST", "/admin/providers", {
116
+ name,
117
+ baseUrlOpenai: opts.baseUrlOpenai,
118
+ baseUrlAnthropic: opts.baseUrlAnthropic,
119
+ apiKey: opts.key,
120
+ formats,
121
+ });
122
+ console.log(`Added provider ${(r as any).provider.name} (${(r as any).provider.id})`);
123
+ });
124
+
125
+ provider.command("list").action(async () => {
126
+ const r = (await api(ctx(), "GET", "/admin/providers")) as { providers: any[] };
127
+ if (!r.providers.length) return console.log("No providers yet. Add one: myapikey provider add <name> ...");
128
+ for (const p of r.providers)
129
+ console.log(`${p.id} ${p.name} [${p.formats.join(",")}] openai:${p.baseUrlOpenai || "-"} anthropic:${p.baseUrlAnthropic || "-"} key:${p.apiKey}`);
130
+ });
131
+
132
+ async function resolveProviderId(ref: string): Promise<string> {
133
+ const r = (await api(ctx(), "GET", "/admin/providers")) as { providers: any[] };
134
+ const byId = r.providers.find((p) => p.id === ref);
135
+ if (byId) return byId.id;
136
+ const byName = r.providers.filter((p) => p.name === ref);
137
+ if (byName.length === 1) return byName[0].id;
138
+ if (byName.length > 1) throw new Error(`Multiple providers named '${ref}'; use the id.`);
139
+ throw new Error(`No provider matching '${ref}'.`);
140
+ }
141
+
142
+ provider.command("remove <ref>").description("remove by id or name").action(async (ref: string) => {
143
+ const id = await resolveProviderId(ref);
144
+ await api(ctx(), "DELETE", `/admin/providers/${id}`);
145
+ console.log(`Removed provider ${id}.`);
146
+ });
147
+
148
+ provider
149
+ .command("models <ref>")
150
+ .description("discover available models from a backend")
151
+ .action(async (ref: string) => {
152
+ const id = await resolveProviderId(ref);
153
+ const r = (await api(ctx(), "POST", `/admin/providers/${id}/discover`)) as { models: string[] };
154
+ if (!r.models.length) return console.log("No models discovered (check base url / key / formats).");
155
+ for (const m of r.models) console.log(m);
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // model
160
+ // ---------------------------------------------------------------------------
161
+ const model = program.command("model").description("manage the routing table");
162
+
163
+ /** Shared --format flag: every model mutation acts on one routing slot. */
164
+ function fmtOption() {
165
+ return new Option("-f, --format <fmt>", "routing slot to act on")
166
+ .choices(["openai", "anthropic", "responses"])
167
+ .makeOptionMandatory();
168
+ }
169
+
170
+ model.command("list").action(async () => {
171
+ const r = (await api(ctx(), "GET", "/admin/models")) as { models: any[] };
172
+ if (!r.models.length) return console.log("No models configured.");
173
+ const fmts = ["openai", "anthropic", "responses"] as const;
174
+ for (const m of r.models) {
175
+ console.log(m.name);
176
+ for (const f of fmts) {
177
+ const fe = m[f];
178
+ const chain = fe.providers.map((p: any) => p.name).join(" → ") || "(none)";
179
+ console.log(` ${f.padEnd(9)} ${fe.enabled ? "✓" : "·"} ${chain}`);
180
+ }
181
+ }
182
+ });
183
+
184
+ model
185
+ .command("enable <name>")
186
+ .addOption(fmtOption())
187
+ .option("--via <provider>", "provider id or name to route through")
188
+ .action(async (name: string, opts: { format: "openai" | "anthropic"; via?: string }) => {
189
+ let providerId: string | undefined;
190
+ if (opts.via) providerId = await resolveProviderId(opts.via);
191
+ await api(ctx(), "POST", "/admin/models", { name, format: opts.format, providers: providerId ? [providerId] : [] });
192
+ console.log(
193
+ `Enabled ${name} [${opts.format}]${providerId ? ` via ${opts.via}` : ""}. Add fallbacks: myapikey model add-provider ${name} <provider> --format ${opts.format}`,
194
+ );
195
+ });
196
+
197
+ model
198
+ .command("disable <name>")
199
+ .addOption(fmtOption())
200
+ .action(async (name: string, opts: { format: "openai" | "anthropic" }) => {
201
+ await api(ctx(), "POST", `/admin/models/${encodeURIComponent(name)}/disable`, { format: opts.format });
202
+ console.log(`Disabled ${name} [${opts.format}].`);
203
+ });
204
+
205
+ model
206
+ .command("add-provider <name> <ref>")
207
+ .addOption(fmtOption())
208
+ .action(async (name: string, ref: string, opts: { format: "openai" | "anthropic" }) => {
209
+ const providerId = await resolveProviderId(ref);
210
+ await api(ctx(), "POST", `/admin/models/${encodeURIComponent(name)}/providers`, { format: opts.format, providerId });
211
+ console.log(`Added ${ref} to ${name} [${opts.format}].`);
212
+ });
213
+
214
+ model
215
+ .command("remove-provider <name> <ref>")
216
+ .addOption(fmtOption())
217
+ .action(async (name: string, ref: string, opts: { format: "openai" | "anthropic" }) => {
218
+ const providerId = await resolveProviderId(ref);
219
+ await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}/providers/${providerId}?format=${opts.format}`);
220
+ console.log(`Removed ${ref} from ${name} [${opts.format}].`);
221
+ });
222
+
223
+ model
224
+ .command("prioritize <name> <refs...>")
225
+ .description("set provider priority order (left = primary)")
226
+ .addOption(fmtOption())
227
+ .action(async (name: string, refs: string[], opts: { format: "openai" | "anthropic" }) => {
228
+ const ids: string[] = [];
229
+ for (const ref of refs) ids.push(await resolveProviderId(ref));
230
+ await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/priority`, { format: opts.format, providers: ids });
231
+ console.log(`Priority for ${name} [${opts.format}]: ${refs.join(" → ")}`);
232
+ });
233
+
234
+ model.command("remove <name>").description("remove a model entirely (both formats)").action(async (name: string) => {
235
+ await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}`);
236
+ console.log(`Removed ${name}.`);
237
+ });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // call
241
+ // ---------------------------------------------------------------------------
242
+ program
243
+ .command("call <model> [prompt...]")
244
+ .description("quick test a model through the gateway")
245
+ .action(async (modelName: string, promptParts: string[]) => {
246
+ const prompt = promptParts.join(" ").trim();
247
+ const input = prompt || (await readStdin());
248
+ if (!input) return console.log("Provide a prompt: myapikey call <model> hello");
249
+ const r = (await api(ctx(), "POST", "/v1/chat/completions", {
250
+ model: modelName,
251
+ messages: [{ role: "user", content: input }],
252
+ })) as any;
253
+ const content = r?.choices?.[0]?.message?.content;
254
+ console.log(typeof content === "string" ? content : JSON.stringify(content ?? r));
255
+ });
256
+
257
+ function readStdin(): Promise<string> {
258
+ return new Promise((res) => {
259
+ let data = "";
260
+ if (process.stdin.isTTY) return res("");
261
+ process.stdin.setEncoding("utf8");
262
+ process.stdin.on("data", (c) => (data += c));
263
+ process.stdin.on("end", () => res(data));
264
+ });
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ program.parseAsync().catch((e) => {
269
+ if (e instanceof ApiError) console.error(`Error ${e.status}: ${e.message}`);
270
+ else console.error(e?.message ?? String(e));
271
+ process.exit(1);
272
+ });