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
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
|
+
});
|