myapikey 0.48.2 → 1.0.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/README.md CHANGED
@@ -2,133 +2,76 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/myapikey?logo=npm)](https://www.npmjs.com/package/myapikey)
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
5
- [![node](https://img.shields.io/badge/node.js-%3E%3E18-339933?logo=node.js&logoColor=white)](#up-and-running-in-60-seconds)
5
+ [![node](https://img.shields.io/badge/node.js-%3E%3D18-339933?logo=node.js&logoColor=white)](#quick-start)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](#development)
7
7
 
8
8
  English | [简体中文](README.zh-CN.md)
9
9
 
10
- **One address, one key, every AI tool you use.** A tiny self-hosted LLM gateway for your home server: keep your provider keys in one place, point every tool at the same URL with the same key, and fail over automatically when a backend breaks.
10
+ A lightweight self-hosted LLM gateway. Tools point at one URL with one gateway key; provider keys, model routing, failover, and logging live on your own server.
11
11
 
12
- ## Sound familiar?
12
+ ## Why use it
13
13
 
14
- - You use **Claude Code, a few OpenAI-compatible CLIs, editor plugins, scripts** — and each one needs its own base URL + API key pasted in.
15
- - Your **real keys** (OpenAI, Anthropic, OpenRouter, a local Ollama…) are scattered across a dozen config files. Rotating one means remembering everywhere it's pasted.
16
- - Your **main provider 429s or goes down** mid-task and the tool just dies. Switching to a backup means re-editing configs.
17
- - You tried **one-api / new-api-style gateways**, which *translate* between formats — and drop fields, mangle streams, or hold new upstream parameters hostage until someone updates the gateway.
14
+ - **Keep provider keys private:** real public-cloud keys stay only on your gateway. Client tools receive a local gateway key, so a leaked tool config does not expose the upstream account.
15
+ - **Own the model interface:** expose stable model names to tools. If a provider, deployment, or pricing changes, update the mapping and routing—client configurations stay unchanged.
16
+ - **Stay protocol-aware:** choose OpenAI or Anthropic format explicitly and forward it without translation. You know which protocol each tool and backend is using, and upstream fields do not depend on a converter catching up.
17
+ - **Light enough to modify:** one focused TypeScript codebase with no database and a file-based store. It is easy to run, inspect, and adapt to your own ideas—without first learning a heavyweight gateway.
18
18
 
19
- MyAPIKey is built to end exactly this.
20
-
21
- ## What you get
22
-
23
- - **One address, one key.** Every tool points at `http://<your-server>:7800` with a single `sk-myapikey-…` key. Real provider keys live only inside the gateway — rotate one, swap a backend, and no tool config ever changes again.
24
- - **Automatic failover + circuit breaker.** Give a model an ordered chain of backends. On `429` / `5xx` / timeout the next one takes over; a source that keeps failing cools down (30 s → 5 min) so later calls skip it. Your agent never notices.
25
- - **Pure passthrough, zero translation.** OpenAI-format calls reach OpenAI-speaking backends, Anthropic-format calls reach Anthropic-speaking backends — bodies forwarded byte-for-byte, streaming straight through. New upstream fields and parameters work without the gateway "supporting" them first.
26
- - **Rate limiting that queues instead of 429ing.** Cap each backend's rpm, or give a model an even per-minute pace; excess calls wait in the gateway instead of coming straight back as `429`.
27
- - **Model name mapping.** Expose a friendly name to your tools (`claude-sonnet-4`); each backend maps it to its own real id (`claude-sonnet-4-20250514` here, something else there).
28
- - **A default thinking level per slot.** Pin a reasoning effort / token budget on a route; it overrides whatever the request carried — handy for taming Claude Code.
29
- - **See everything.** Web UI (English / 简体中文): live call log, success rate, p50/p95 latency, per-model / per-backend / per-day stats. The CLI can do all of it too.
30
- - **Featherweight.** No database — one `data.json` plus a `logs.jsonl`. Runs with `npx`. Built for LAN / home-server use.
31
-
32
- ## Up and running in 60 seconds
19
+ ## Quick start
33
20
 
34
21
  Requires Node.js 18+.
35
22
 
36
23
  ```bash
37
- npx myapikey serve # → http://localhost:7800
24
+ npx myapikey
38
25
  ```
39
26
 
40
- First run prints your credentials and also saves them to `~/.myapikey/credentials.txt`:
27
+ Open `http://localhost:7800` and sign in with the credentials printed on first startup (also saved to `~/.myapikey/credentials.txt`).
41
28
 
42
- 1. Open `http://localhost:7800` and sign in with the printed **username / password** (web login).
43
- 2. **Models** tab → **Add backend**: paste the backend's base URL + its real API key, tick the formats it speaks (`openai` / `anthropic`), then **discover** → **enable** the models you want.
44
- 3. Point your tools at the gateway — every tool's "API key" field gets the **gateway key**, not your web password:
29
+ 1. In **Models**, add each backend with its real base URL and API key, select its format (`openai` / `anthropic`), discover models, and enable the ones you need.
30
+ 2. Point tools at the gateway and use the generated `sk-myapikey-…` key, **not** your web login password:
45
31
 
46
32
  ```bash
47
- # Claude Code / anything Anthropic
33
+ # Claude Code / Anthropic SDKs
48
34
  export ANTHROPIC_BASE_URL=http://localhost:7800/anthropic
49
35
  export ANTHROPIC_API_KEY=sk-myapikey-…
50
36
 
51
- # OpenAI-compatible tools (Codex, CLIs, plugins)
37
+ # OpenAI-compatible tools
52
38
  export OPENAI_BASE_URL=http://localhost:7800/openai/v1
53
39
  export OPENAI_API_KEY=sk-myapikey-…
54
40
  ```
55
41
 
56
- > Why `…/openai/v1` but `…/anthropic`? Each SDK appends its own paths (OpenAI adds `/chat/completions`, Anthropic adds `/v1/messages`), so the gateway follows each ecosystem's own convention. Each surface also has its own `/models`, listing only models enabled for that format. `myapikey whoami` prints all of this ready to paste, anytime.
57
-
58
- 4. Smoke test without any tool:
59
-
60
- ```bash
61
- myapikey call gpt-4o-mini "Say hello in one sentence."
62
- ```
63
-
64
- Prefer the terminal? `provider add` → `provider models` → `model enable` does the same job — cheat sheet below.
65
-
66
- ## How routing works (the whole story)
42
+ OpenAI base URLs include the version segment (`/v1`, `/api/v3`); Anthropic base URLs exclude it.
67
43
 
68
- - The gateway has two entrances: `/openai/v1/*` (chat completions, responses, models) and `/anthropic/v1/*` (messages, models). A request is forwarded in the entrance's format, **never translated**.
69
- - Each model has an independent, ordered backend chain **per format**. Within a chain, `429` / `5xx` / timeouts fail over to the next backend; other `4xx` errors come back as-is (they're the caller's fault, not the backend's). No failover once streaming has started. If no backend serves that model on that format, you get a `404` — a clear failure beats a silent translation.
70
- - Anything that speaks either format works: OpenAI, Anthropic, OpenRouter, Ollama / vLLM, Volcengine Ark, your company's internal gateway…
44
+ ## What it does
71
45
 
72
- ## Why no translation?
46
+ - **Unified access:** one endpoint and gateway key for Claude Code, OpenAI-compatible CLIs, plugins, and scripts.
47
+ - **Pure passthrough:** OpenAI calls reach OpenAI-format backends; Anthropic calls reach Anthropic-format backends. Bodies and streams are not translated, so new upstream parameters work without gateway support.
48
+ - **Reliability:** configure ordered backends per model and format. `429`, `5xx`, and timeouts fail over; repeated failures trigger a 30-second-to-5-minute cooldown. Streaming is not retried after it starts.
49
+ - **Rate control:** backend RPM or model pacing queues excess requests instead of immediately returning `429`.
50
+ - **Friendly routing:** expose a simple model name and map it to each backend’s real model ID; optionally pin a default thinking level per route.
51
+ - **Visibility:** bilingual web UI with live logs, success rates, and p50/p95 latency by model, backend, and day.
52
+ - **Light storage:** no database—only `data.json` and `logs.jsonl`.
73
53
 
74
- one-api / new-api convert OpenAI ↔ Anthropic shapes. Convenient, until a field gets dropped, a stream gets mangled, or a new upstream parameter needs a gateway update before you can send it. OpenRouter is hosted and takes a cut per call. MyAPIKey's answer is to not have a translation layer at all:
54
+ Requests enter through `/openai/v1/*` or `/anthropic/v1/*` and stay in that format. Unsupported model/format combinations return `404`; other caller-side `4xx` errors return unchanged. If you need OpenAI ↔ Anthropic translation, this gateway is intentionally not that tool.
75
55
 
76
- | | MyAPIKey | one-api / new-api | OpenRouter |
77
- |---|---|---|---|
78
- | Deploy | self-hosted, one `npx` | self-hosted, heavier | hosted service |
79
- | Cost | free | free | markup per call |
80
- | Formats | passthrough, byte-for-byte | translated | translated |
81
- | New upstream params | work immediately | wait for gateway support | wait for platform support |
82
- | Provider keys | on your machine | on your machine | on their platform |
56
+ ## Operations
83
57
 
84
- The trade-off, stated plainly: an Anthropic-format backend cannot be called through the OpenAI entrance. If what you want is format conversion, this isn't your tool (yet).
58
+ Data defaults to `~/.myapikey`; override it with `--data-dir` or `MYAPIKEY_DATA_DIR`.
85
59
 
86
- ## Managing it
87
-
88
- **Web UI** — `http://localhost:7800`, English + 简体中文. Five tabs: **Connect** (copy-paste-ready env lines), **Models** (backends, discovery, per-format enable, drag-to-order chains, name mapping, test call), **Logs** (live timeline), **Stats** (counts, success rate, p50/p95 by model / backend / day), **Settings** (rotate API key, change password, circuit-breaker state, storage paths).
89
-
90
- **CLI cheat sheet** (same admin API as the web UI — the two never drift apart):
91
-
92
- | I want to… | Command |
60
+ | File | Contents |
93
61
  |---|---|
94
- | run the gateway | `myapikey serve [--port 7800] [--data-dir <dir>]` |
95
- | print what to paste into tools | `myapikey whoami` |
96
- | add a backend | `myapikey provider add <name> --base-url-openai https://api.openai.com/v1 --key sk-… --formats openai` |
97
- | see a backend's models | `myapikey provider models <name>` |
98
- | enable a model | `myapikey model enable <model> --format openai --via <backend>` |
99
- | add a fallback + set order | `myapikey model add-provider <model> <backend> --format openai` · `myapikey model prioritize <model> <primary> <backup> --format openai` |
100
- | see the routing table | `myapikey model list` |
101
- | pace a model | `myapikey model pace <model> <rpm\|0>` |
102
- | pin a slot's thinking level | `myapikey model thinking <model> <index> [value] --format <fmt>` |
103
- | quick test | `myapikey call <model> "hi"` |
104
-
105
- > OpenAI base URLs **include** the version segment (`/v1`, Ark's `/api/v3`); Anthropic base URLs **exclude** it (`https://api.anthropic.com`). The `responses` format only accepts backends you've marked `supportsResponses` (web UI toggle).
106
- > CLI global flags: `-u/--url`, `--user`, `--pass`, `--api-key`, or env `MYAPIKEY_URL` / `MYAPIKEY_USER` / `MYAPIKEY_PASS` / `MYAPIKEY_API_KEY`.
107
-
108
- ## Where your data lives
62
+ | `data.json` | backends, routing, account, and gateway API key |
63
+ | `logs.jsonl` | call history (~90 days / 1M lines) |
64
+ | `credentials.txt` | web credentials and gateway key, rewritten at startup |
109
65
 
110
- One directory (default `~/.myapikey`, override with `--data-dir` or `MYAPIKEY_DATA_DIR`):
111
-
112
- | File | What |
113
- |---|---|
114
- | `data.json` | all config: backends, routing table, account, API key |
115
- | `logs.jsonl` | call history (kept ~90 days / 1 M lines) |
116
- | `credentials.txt` | your login + API key in plain text, rewritten every start |
117
- | `client.json` | the CLI's saved connection profile |
66
+ The web UI includes Connect, Models, Logs, Stats, and Settings. Settings can rotate the gateway key or change the password. This tool is single-user and has no TLS; place it behind a reverse proxy before exposing it beyond a trusted network.
118
67
 
119
68
  ## Development
120
69
 
121
70
  ```bash
122
71
  npm install
123
- npm run build:web # build the Vue UI into packages/web/dist
124
- npm run dev # gateway with watch reload
125
- npm run dev:web # vite dev server (proxies API calls to :7800)
126
- npm test # vitest unit + integration
127
- npm run typecheck # tsc + vue-tsc
72
+ npm run build:web
73
+ npm run dev
74
+ npm run dev:web
75
+ npm test
76
+ npm run typecheck
128
77
  ```
129
-
130
- ## Honest limits
131
-
132
- - **No OpenAI↔Anthropic translation.** Wrong entrance for a model → `404`.
133
- - **Single user, no TLS.** Meant for your own network; put a reverse proxy in front if you expose it.
134
- - **Web password ≠ API key.** The password administers the UI/CLI; agents use the `sk-myapikey-…` key. If a tool config leaks, rotate the key (Settings, or `POST /admin/api-key/rotate`) and nothing else changes.
package/README.zh-CN.md CHANGED
@@ -2,133 +2,76 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/myapikey?logo=npm)](https://www.npmjs.com/package/myapikey)
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
5
- [![node](https://img.shields.io/badge/node.js-%3E%3E18-339933?logo=node.js&logoColor=white)](#60-秒跑起来)
6
- [![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](#开发)
5
+ [![node](https://img.shields.io/badge/node.js-%3E%3D18-339933?logo=node.js&logoColor=white)](#quick-start)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](#development)
7
7
 
8
8
  [English](README.md) | 简体中文
9
9
 
10
- **一个地址、一把 key,接上你所有的 AI 工具。** 一个跑在自己家用服务器上的轻量 LLM 网关:供应商的 key 收进网关统一保管,所有工具只认同一个地址 + 同一把 key,后端挂了自动切换。
10
+ 轻量自托管 LLM 网关。所有工具只填一个地址和一把网关 key;供应商 key、模型路由、故障转移和调用记录都保存在你自己的服务器上。
11
11
 
12
- ## 是不是在说你
12
+ ## Why use it
13
13
 
14
- - 你用着 **Claude Code、好几个 OpenAI 兼容 CLI、编辑器插件、脚本**——每个都得单独填一遍 base URL + API key。
15
- - 你的**真实 key**(OpenAI、Anthropic、OpenRouter、本地 Ollama……)散落在十几个配置文件里。想换一把 key?先想清楚它粘在了哪儿。
16
- - **主力供应商一限流或宕机**,活儿干到一半就断。切备用?又得改一轮配置。
17
- - 你试过 **one-api / new-api 这类网关**,它们在格式之间做*翻译*——然后你遇到丢字段、流式错乱、新参数要等网关适配才能用。
14
+ - **保护云端 key:** 真实的公网供应商 key 只保存在自己的网关里。客户端工具拿到的是本地网关 key,即使工具配置泄露,也不会直接暴露上游账号。
15
+ - **自定义模型接口:** 对工具暴露稳定的模型名。供应商、部署方式或价格变化时,只需要更新映射和路由,客户端配置不用改。
16
+ - **明确多协议:** 显式选择 OpenAI 或 Anthropic 格式并原样转发。你清楚每个工具和后端到底用什么协议,上游新字段也不需要等转换层适配。
17
+ - **轻到敢直接改:** 一个专注的 TypeScript 代码库,无数据库、文件存储。拿过去就能读懂和修改,不用先啃一个过于复杂、不适合个人使用的重型网关。
18
18
 
19
- MyAPIKey 就是为治这些而生的。
20
-
21
- ## 你能得到什么
22
-
23
- - **一个地址 + 一把 key。** 所有工具都指向 `http://你的机器:7800`,只用一把 `sk-myapikey-…`。真实供应商 key 只存在网关里——轮换、换后端,都不用再碰任何工具的配置。
24
- - **自动故障转移 + 熔断。** 给同一个模型配一条有序的后端链:429 / 5xx / 超时自动切下一条;连续失败的后端进入冷却(30 秒 → 5 分钟),后续请求先跳过它。你的 agent 毫无感知。
25
- - **纯透传,零翻译。** OpenAI 格式的调用只发给说 OpenAI 格式的后端,Anthropic 同理——请求/响应逐字节转发,流式直通。上游新出的字段、新参数,不需要网关"适配"就能用。
26
- - **限流靠排队,不甩 429。** 给每个后端设 rpm 上限,或给模型设匀速节奏;超出的调用先在网关排队,而不是直接把 429 甩回工具。
27
- - **模型名映射。** 对工具暴露一个好记的名字(`claude-sonnet-4`);每个后端各自映射到真实 id(这里发 `claude-sonnet-4-20250514`,那边发别的)。
28
- - **每个槽位可固定默认 thinking 档位。** 在路由上钉死一个推理档位 / 预算,覆盖请求里带的设置——想管住 Claude Code 的思考行为,网关说了算。
29
- - **全程看得见。** Web 界面(中/英):实时调用日志、成功率、p50/p95 延迟,按模型 / 来源 / 日期的统计;CLI 功能完全对等。
30
- - **轻得没负担。** 无数据库——一个 `data.json` 加一个 `logs.jsonl`;`npx` 一行起跑;为局域网 / 家用服务器设计。
31
-
32
- ## 60 秒跑起来
19
+ ## Quick start
33
20
 
34
21
  需要 Node.js 18+。
35
22
 
36
23
  ```bash
37
- npx myapikey serve # → http://localhost:7800
24
+ npx myapikey
38
25
  ```
39
26
 
40
- 首次启动会打印凭据,并同时存到 `~/.myapikey/credentials.txt`:
27
+ 打开 `http://localhost:7800`,用首次启动打印的凭据登录(也会保存到 `~/.myapikey/credentials.txt`)。
41
28
 
42
- 1. 浏览器打开 `http://localhost:7800`,用打印出来的**用户名 / 密码**登录(网页管理用)。
43
- 2. **模型**页 → **添加后端**:填后端的 base URL + 它的真实 key,勾选它说的格式(`openai` / `anthropic`),然后**刷新模型** → 在对应格式上**启用**你想要的。
44
- 3. 把工具指过来——所有工具里的 "API key" 字段都填**网关 key**,不是网页登录密码:
29
+ 1. 在 **Models** 中添加后端的真实 base URL 和 API key,选择格式(`openai` / `anthropic`),刷新模型并启用需要的模型。
30
+ 2. 让工具指向网关,使用生成的 `sk-myapikey-…`,**不要**填网页登录密码:
45
31
 
46
32
  ```bash
47
- # Claude Code / Anthropic 系
33
+ # Claude Code / Anthropic SDK
48
34
  export ANTHROPIC_BASE_URL=http://localhost:7800/anthropic
49
35
  export ANTHROPIC_API_KEY=sk-myapikey-…
50
36
 
51
- # OpenAI 兼容工具(Codex、各种 CLI / 插件)
37
+ # OpenAI 兼容工具
52
38
  export OPENAI_BASE_URL=http://localhost:7800/openai/v1
53
39
  export OPENAI_API_KEY=sk-myapikey-…
54
40
  ```
55
41
 
56
- > 为什么一个是 `…/openai/v1` 一个是 `…/anthropic`?各生态 SDK 自己补路径(OpenAI 补 `/chat/completions`,Anthropic 补 `/v1/messages`),网关按各自的约定来。两边各有独立的 `/models`,只列出各自格式启用的模型。`myapikey whoami` 随时打印这些可直接粘贴的配置。
57
-
58
- 4. 不开工具也能冒烟测试:
59
-
60
- ```bash
61
- myapikey call gpt-4o-mini "用一句话介绍你自己"
62
- ```
63
-
64
- 喜欢全程命令行?`provider add` → `provider models` → `model enable` 干的是同一件事,速查表在下面。
65
-
66
- ## 路由是怎么回事(就这么多)
67
-
68
- - 网关开两个入口:`/openai/v1/*`(chat completions、responses、models)和 `/anthropic/v1/*`(messages、models)。请求从哪个口进,就按哪个口的格式转发,**绝不翻译**。
69
- - 每个模型在每个格式上有一条独立、有序的后端链。链内按序故障转移:429 / 5xx / 超时切下一条;其他 4xx 原样返回(那是调用方的错);开始流式后不再切换。该格式下没有任何后端能服务这个模型,就返回 `404`——宁可失败得明明白白,也不悄悄翻译。
70
- - 只要"会说"这两种格式的都能接:OpenAI、Anthropic、OpenRouter、Ollama / vLLM、火山 Ark、公司内部网关……
42
+ OpenAI 的 base URL 含版本段(`/v1`、`/api/v3`);Anthropic 的 base URL 不含它。
71
43
 
72
- ## 为什么不做翻译?
44
+ ## What it does
73
45
 
74
- one-api / new-api 在 OpenAI ↔ Anthropic 之间做格式转换,方便是方便,但字段会丢、流式会乱,上游出新参数还得等网关更新;OpenRouter 是托管服务,每次调用抽成。MyAPIKey 的答案是干脆没有翻译层:
46
+ - **统一入口:** Claude Code、OpenAI 兼容 CLI、插件和脚本共用一个地址和网关 key。
47
+ - **纯透传:** OpenAI 请求发给 OpenAI 格式后端,Anthropic 请求发给 Anthropic 格式后端;请求体和流式响应不翻译,上游新参数无需等待网关适配。
48
+ - **可靠性:** 按模型和格式配置有序后端。`429`、`5xx`、超时自动切换;连续失败触发 30 秒到 5 分钟冷却。流式开始后不再重试。
49
+ - **限流控制:** 后端 RPM 或模型匀速节奏会让超限请求排队,而不是立即返回 `429`。
50
+ - **易记路由:** 对外暴露简洁模型名,并映射到每个后端的真实模型 ID;可按路由固定默认 thinking 档位。
51
+ - **运行可见:** 中英文 Web UI 提供实时日志、成功率,以及按模型、后端、日期统计的 p50/p95 延迟。
52
+ - **轻量存储:** 无数据库,只有 `data.json` 和 `logs.jsonl`。
75
53
 
76
- | | MyAPIKey | one-api / new-api | OpenRouter |
77
- |---|---|---|---|
78
- | 部署 | 自托管,`npx` 一行 | 自托管,较重 | 托管服务 |
79
- | 费用 | 免费 | 免费 | 按次加价 |
80
- | 格式 | 逐字节透传 | 翻译 | 翻译 |
81
- | 上游新参数 | 立即可用 | 等网关适配 | 等平台适配 |
82
- | 供应商 key | 在你自己机器上 | 在你自己机器上 | 在平台手里 |
54
+ 请求通过 `/openai/v1/*` 或 `/anthropic/v1/*` 进入,并保持原格式转发。模型和格式不匹配返回 `404`;调用方自身的 `4xx` 错误原样返回。如果你需要 OpenAI ↔ Anthropic 格式互转,这个网关明确不提供该能力。
83
55
 
84
- 代价也直说:Anthropic 格式的后端没法从 OpenAI 入口调。如果你要的就是格式互转,这个工具暂时不适合你。
56
+ ## Operations
85
57
 
86
- ## 怎么管
87
-
88
- **Web 界面** — `http://localhost:7800`,中英双语。五个页:**使用方式**(可直接粘贴的连接配置)、**模型**(后端、发现、按格式启用、拖拽排序、名称映射、测试调用)、**最近调用**(实时时间线)、**统计**(调用数 / 成功率 / p50 / p95,按模型 / 来源 / 日期)、**设置**(轮换 key、改密码、熔断状态、存储位置)。
89
-
90
- **CLI 速查**(和网页同一套 admin API,两边永远一致):
91
-
92
- | 我想…… | 命令 |
93
- |---|---|
94
- | 起网关 | `myapikey serve [--port 7800] [--data-dir <目录>]` |
95
- | 打印要粘给工具的配置 | `myapikey whoami` |
96
- | 加后端 | `myapikey provider add <名> --base-url-openai https://api.openai.com/v1 --key sk-… --formats openai` |
97
- | 看后端有哪些模型 | `myapikey provider models <名>` |
98
- | 启用模型 | `myapikey model enable <模型> --format openai --via <后端>` |
99
- | 加备用 + 排序 | `myapikey model add-provider <模型> <后端> --format openai` · `myapikey model prioritize <模型> <主> <备> --format openai` |
100
- | 看路由表 | `myapikey model list` |
101
- | 给模型设匀速限速 | `myapikey model pace <模型> <rpm\|0>` |
102
- | 钉死槽位 thinking 档位 | `myapikey model thinking <模型> <链上位次> [档位] --format <格式>` |
103
- | 快速试一把 | `myapikey call <模型> "你好"` |
104
-
105
- > OpenAI 的 base URL **含**版本段(`/v1`、火山 Ark 的 `/api/v3`);Anthropic 的 base URL **不含**(`https://api.anthropic.com`)。`responses` 格式只接受你标记了 `supportsResponses` 的后端(网页里勾选)。
106
- > CLI 全局参数:`-u/--url`、`--user`、`--pass`、`--api-key`,或环境变量 `MYAPIKEY_URL` / `MYAPIKEY_USER` / `MYAPIKEY_PASS` / `MYAPIKEY_API_KEY`。
107
-
108
- ## 数据都在哪
109
-
110
- 一个目录(默认 `~/.myapikey`,可用 `--data-dir` 或 `MYAPIKEY_DATA_DIR` 改):
58
+ 数据默认在 `~/.myapikey`;可用 `--data-dir` 或 `MYAPIKEY_DATA_DIR` 覆盖。
111
59
 
112
60
  | 文件 | 内容 |
113
61
  |---|---|
114
- | `data.json` | 全部配置:后端、路由表、账号、API key |
115
- | `logs.jsonl` | 调用历史(保留约 90 天 / 100 万行) |
116
- | `credentials.txt` | 明文的登录信息 + API key,每次启动重写 |
117
- | `client.json` | CLI 自己存的连接配置 |
62
+ | `data.json` | 后端、路由、账号和网关 API key |
63
+ | `logs.jsonl` | 调用历史(约 90 天 / 100 万行) |
64
+ | `credentials.txt` | 网页凭据和网关 key,启动时重写 |
65
+
66
+ Web UI 包含 Connect、Models、Logs、Stats 和 Settings。设置页可轮换网关 key、修改密码。工具为单用户设计且不内置 TLS;暴露到可信网络之外时请使用反向代理。
118
67
 
119
- ## 开发
68
+ ## Development
120
69
 
121
70
  ```bash
122
71
  npm install
123
- npm run build:web # 把 Vue 界面构建到 packages/web/dist
124
- npm run dev # 网关,带 watch 热重载
125
- npm run dev:web # vite 开发服务器(API 代理到 :7800)
126
- npm test # vitest 单测 + 集成
127
- npm run typecheck # tsc + vue-tsc
72
+ npm run build:web
73
+ npm run dev
74
+ npm run dev:web
75
+ npm test
76
+ npm run typecheck
128
77
  ```
129
-
130
- ## 丑话说在前面
131
-
132
- - **不做 OpenAI ↔ Anthropic 翻译。** 入口和模型格式对不上 → `404`。
133
- - **单用户、无 TLS。** 给你自己的内网用的;要暴露到外网请自己套反代。
134
- - **网页密码 ≠ API key。** 密码只管 UI/CLI,agent 只认 `sk-myapikey-…`。工具配置泄露了,去设置页轮换 key,其他什么都不用动。
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.48.2",
3
+ "version": "1.0.0",
4
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).",
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 web UI.",
6
6
  "keywords": [
7
7
  "llm",
8
8
  "llm-gateway",
@@ -26,7 +26,6 @@
26
26
  "multi-provider",
27
27
  "self-hosted",
28
28
  "homelab",
29
- "cli",
30
29
  "hono",
31
30
  "typescript",
32
31
  "vue"
@@ -42,22 +41,20 @@
42
41
  "license": "MIT",
43
42
  "author": "vat-wiki",
44
43
  "bin": {
45
- "myapikey": "packages/core/src/cli/index.ts"
44
+ "myapikey": "src/server/index.ts"
46
45
  },
47
46
  "files": [
48
- "packages/core/src",
49
- "packages/web/dist"
50
- ],
51
- "workspaces": [
52
- "packages/*"
47
+ "src/server",
48
+ "src/shared",
49
+ "dist"
53
50
  ],
54
51
  "scripts": {
55
- "dev": "NODE_ENV=development tsx watch packages/core/src/cli/index.ts serve",
56
- "dev:web": "npm run dev -w @myapikey/web",
52
+ "dev": "NODE_ENV=development tsx watch src/server/index.ts",
53
+ "dev:web": "vite src/web",
57
54
  "start": "npm run build:web && npm run serve",
58
- "serve": "tsx packages/core/src/cli/index.ts serve",
59
- "typecheck": "tsc -p packages/core/tsconfig.json --noEmit && tsc -p packages/core/tsconfig.test.json --noEmit && vue-tsc --noEmit -p packages/web/tsconfig.json",
60
- "build:web": "npm run build -w @myapikey/web",
55
+ "serve": "tsx src/server/index.ts",
56
+ "typecheck": "tsc --noEmit && vue-tsc --noEmit -p src/web/tsconfig.json",
57
+ "build:web": "vite build src/web",
61
58
  "test": "vitest run",
62
59
  "test:watch": "vitest",
63
60
  "test:coverage": "vitest run --coverage"
@@ -68,17 +65,29 @@
68
65
  "dependencies": {
69
66
  "@hono/node-server": "^1.13.5",
70
67
  "@hono/zod-validator": "^0.4.2",
71
- "commander": "^12.1.0",
72
68
  "gpt-tokenizer": "^3.4.0",
73
69
  "hono": "^4.6.12",
74
70
  "tsx": "^4.19.0",
75
71
  "zod": "^3.23.8"
76
72
  },
77
73
  "devDependencies": {
74
+ "@tailwindcss/vite": "^4.3.3",
75
+ "class-variance-authority": "^0.7.1",
76
+ "clsx": "^2.1.1",
77
+ "lucide-vue-next": "^1.0.0",
78
+ "reka-ui": "^2.10.1",
79
+ "tailwind-merge": "^3.6.0",
80
+ "tailwindcss": "^4.3.3",
81
+ "tw-animate-css": "^1.4.0",
82
+ "vue": "^3.5.13",
83
+ "vue-i18n": "^11.4.8",
78
84
  "@types/node": "^22.7.0",
79
85
  "@vitest/coverage-v8": "^2.1.9",
86
+ "@vitejs/plugin-vue": "^5.2.1",
80
87
  "jsdom": "^30.0.1",
81
88
  "typescript": "^5.6.0",
82
- "vitest": "^2.1.9"
89
+ "vite": "^5.4.11",
90
+ "vitest": "^2.1.9",
91
+ "vue-tsc": "^2.1.10"
83
92
  }
84
93
  }
@@ -39,8 +39,8 @@ export function extractSecret(c: Context): { password: string; username?: string
39
39
  export function authMiddleware(getUser: () => string, getPass: () => string, logger?: Logger): MiddlewareHandler {
40
40
  return async (c, next) => {
41
41
  // Dev convenience: `npm run dev` sets NODE_ENV=development, and iterating on
42
- // the UI/CLI against scratch data dirs shouldn't fight a fresh random
43
- // password on every run. Production (`serve`) never sees this.
42
+ // the UI against scratch data dirs shouldn't fight a fresh random
43
+ // password on every run. Production never sees this.
44
44
  if (process.env.NODE_ENV === "development") return next();
45
45
  const cred = extractSecret(c);
46
46
  const ok =
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env tsx
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseArgs } from "node:util";
6
+ import { serve } from "@hono/node-server";
7
+ import { createApp } from "./app";
8
+ import { Store } from "./store";
9
+ import { DEFAULT_DATA_DIR, DEFAULT_PORT } from "../shared/config";
10
+
11
+ const { values } = parseArgs({
12
+ options: {
13
+ port: { type: "string", default: String(DEFAULT_PORT) },
14
+ "data-dir": { type: "string" },
15
+ "web-dir": { type: "string" },
16
+ },
17
+ });
18
+
19
+ const dataDir = resolve(values["data-dir"] ?? process.env.MYAPIKEY_DATA_DIR ?? DEFAULT_DATA_DIR);
20
+ const firstRun = !existsSync(join(dataDir, "data.json"));
21
+ const store = new Store(dataDir);
22
+ const credentialsFile = store.writeCredentialsFile();
23
+ const defaultWebDir = fileURLToPath(new URL("../../dist", import.meta.url));
24
+ const webDir = existsSync(values["web-dir"] ?? defaultWebDir)
25
+ ? resolve(values["web-dir"] ?? defaultWebDir)
26
+ : undefined;
27
+ const app = createApp(store, { webDir });
28
+ const port = Number(values.port);
29
+
30
+ serve({ fetch: app.fetch, port }, async (info) => {
31
+ const url = `http://localhost:${info.port}`;
32
+ console.log(`\n MyAPIKey listening on ${url}`);
33
+ console.log(` web UI: ${webDir ? url : "not built (run: npm run build:web)"}`);
34
+ console.log(` proxy: ${url}/openai-chat/v1/chat/completions (OpenAI chat)`);
35
+ console.log(` ${url}/openai-responses/v1/responses (OpenAI Responses)`);
36
+ console.log(` ${url}/anthropic/v1/messages (Anthropic)`);
37
+ console.log(` data: ${dataDir} (override with --data-dir or MYAPIKEY_DATA_DIR)`);
38
+ console.log(` log: ${store.getPaths().serverLogFile} (errors + failover/cooldown events; level via MYAPIKEY_LOG_LEVEL)\n`);
39
+ store.getLogger().info(`gateway started on port ${info.port}, data=${dataDir}`);
40
+
41
+ if (firstRun) {
42
+ const { account, apiKey } = store.get();
43
+ console.log(" First run — here are your credentials (save them):");
44
+ console.log(` username : ${account.username} (web login)`);
45
+ console.log(` password : ${account.password} (web login)`);
46
+ console.log(` api key : ${apiKey} (put this in the tool's "api key" field)`);
47
+ console.log(` ↳ also written to ${credentialsFile} (cat it anytime if you forget)\n`);
48
+ }
49
+ });
@@ -584,17 +584,14 @@ export function proxyApi(
584
584
  const chat = new Hono();
585
585
  const responses = new Hono();
586
586
  const anthropic = new Hono();
587
- // GET /models is a PUBLIC discovery read — no api key required. It returns only
588
- // the enabled model names (like /health), so an agent or a quick curl can see
589
- // what each surface offers before wiring up auth. Registered BEFORE the auth
590
- // middleware so it isn't gated: Hono only runs middleware on routes registered
591
- // after it.
592
- chat.get("/models", (c) => modelsList(c, store, "openai"));
593
- responses.get("/models", (c) => modelsList(c, store, "responses"));
594
- anthropic.get("/models", (c) => modelsList(c, store, "anthropic"));
595
587
  chat.use("*", auth);
596
588
  responses.use("*", auth);
597
589
  anthropic.use("*", auth);
590
+ // Model lists sit behind the same gateway key: the enabled model names reveal
591
+ // routing and usage patterns, so they are not a public discovery surface.
592
+ chat.get("/models", (c) => modelsList(c, store, "openai"));
593
+ responses.get("/models", (c) => modelsList(c, store, "responses"));
594
+ anthropic.get("/models", (c) => modelsList(c, store, "anthropic"));
598
595
 
599
596
  /** Shared dispatch with failover. `key` selects the routing slot (and thus the
600
597
  * candidate chain); `wire`/`path` derive from it for the upstream call. */
@@ -1,69 +0,0 @@
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 /openai-chat/v1 + /openai-responses/v1 + /anthropic/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
- // The agent surfaces (/openai-chat/v1, /openai-responses/v1, /anthropic/v1)
38
- // take the API key (Bearer); everything else (/admin) takes account Basic.
39
- const isProxy = path.startsWith("/openai-chat/") || path.startsWith("/openai-responses/") || path.startsWith("/anthropic/");
40
- if (isProxy && !ctx.apiKey) {
41
- throw new Error("No API key for /openai-chat/v1, /openai-responses/v1 or /anthropic/v1. Run `myapikey serve`, set MYAPIKEY_API_KEY, or pass --api-key.");
42
- }
43
- if (!isProxy && !ctx.auth) {
44
- throw new Error("No account credentials for /admin. Run `myapikey serve`, set MYAPIKEY_USER/MYAPIKEY_PASS, or pass --user/--pass.");
45
- }
46
- const res = await fetch(`${ctx.url}${path}`, {
47
- method,
48
- headers: {
49
- authorization: isProxy ? `Bearer ${ctx.apiKey}` : ctx.auth,
50
- ...(body ? { "content-type": "application/json" } : {}),
51
- },
52
- body: body ? JSON.stringify(body) : undefined,
53
- });
54
- const text = await res.text();
55
- let json: unknown = null;
56
- try {
57
- json = text ? JSON.parse(text) : null;
58
- } catch {
59
- /* keep as text */
60
- }
61
- if (!res.ok) {
62
- const msg =
63
- (json as { error?: { message?: string } } | null)?.error?.message ??
64
- text ??
65
- res.statusText;
66
- throw new ApiError(res.status, String(msg));
67
- }
68
- return json as T;
69
- }
@@ -1,64 +0,0 @@
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
- }
@@ -1,404 +0,0 @@
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 /openai-chat/v1 + /openai-responses/v1 + /anthropic/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}/openai-chat/v1/chat/completions (OpenAI chat)`);
55
- console.log(` ${url}/openai-responses/v1/responses (OpenAI Responses)`);
56
- console.log(` ${url}/anthropic/v1/messages (Anthropic)`);
57
- console.log(` data: ${dataDir} (override with --data-dir or MYAPIKEY_DATA_DIR)`);
58
- console.log(` log: ${store.getPaths().serverLogFile} (errors + failover/cooldown events; level via MYAPIKEY_LOG_LEVEL)\n`);
59
- store.getLogger().info(`gateway started on port ${info.port}, data=${dataDir}`);
60
-
61
- if (firstRun) {
62
- const { account, apiKey } = store.get();
63
- console.log(" First run — here are your credentials (save them):");
64
- console.log(` username : ${account.username} (web login)`);
65
- console.log(` password : ${account.password} (web login)`);
66
- console.log(` api key : ${apiKey} (put this in the tool's "api key" field)`);
67
- console.log(` ↳ also written to ${credentialsFile} (cat it anytime if you forget)\n`);
68
- saveProfile({ url, username: account.username, password: account.password, apiKey });
69
- console.log(` Saved to ${CLIENT_PROFILE_PATH} for CLI use.`);
70
- console.log(` Next: myapikey provider add <name> --base-url-openai <url> --key <key> --formats openai,anthropic\n`);
71
- }
72
- });
73
- });
74
-
75
- // ---------------------------------------------------------------------------
76
- // whoami
77
- // ---------------------------------------------------------------------------
78
- program
79
- .command("whoami")
80
- .description("print connection info for wiring up agents")
81
- .action(() => {
82
- const profile = loadProfile();
83
- if (!profile) {
84
- console.log("No saved profile. Run `myapikey serve` first (or use --url/--api-key).");
85
- return;
86
- }
87
- const apiKey = resolveApiKey() ?? profile.apiKey;
88
- console.log("Connection info:");
89
- console.log(` base url : ${profile.url}`);
90
- if (apiKey) {
91
- console.log(` api key : ${apiKey} ← put this in the tool's "api key" field`);
92
- } else {
93
- console.log(` api key : (not saved — run \`myapikey serve\` on your gateway, or set MYAPIKEY_API_KEY)`);
94
- }
95
- console.log(` login : ${profile.username} / ${profile.password} ← only for the web UI\n`);
96
- if (apiKey) {
97
- console.log("Example (OpenAI SDK):");
98
- console.log(` OPENAI_BASE_URL=${profile.url}/openai-chat/v1 OPENAI_API_KEY=${apiKey}`);
99
- console.log("\nExample (Claude Code / Anthropic):");
100
- console.log(` ANTHROPIC_BASE_URL=${profile.url}/anthropic ANTHROPIC_API_KEY=${apiKey}`);
101
- }
102
- });
103
-
104
- // ---------------------------------------------------------------------------
105
- // provider
106
- // ---------------------------------------------------------------------------
107
- const provider = program.command("provider").description("manage backends");
108
-
109
- provider
110
- .command("add <name>")
111
- .option("--base-url-openai <url>", "OpenAI base URL incl. version, e.g. https://api.openai.com/v1", "")
112
- .option("--base-url-anthropic <url>", "Anthropic base URL excl. /v1, e.g. https://api.anthropic.com", "")
113
- .option("--key <key>", "api key for the backend", "")
114
- .option("--formats <list>", "comma list: openai,anthropic", "openai")
115
- .option("--rpm <n>", "optional request-per-minute cap (pace a free/limited key)", "")
116
- .action(async (name: string, opts: { baseUrlOpenai: string; baseUrlAnthropic: string; key: string; formats: string; rpm: string }) => {
117
- const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean) as ("openai" | "anthropic")[];
118
- const rpm = Number(opts.rpm);
119
- const body: Record<string, unknown> = {
120
- name,
121
- baseUrlOpenai: opts.baseUrlOpenai,
122
- baseUrlAnthropic: opts.baseUrlAnthropic,
123
- apiKey: opts.key,
124
- formats,
125
- };
126
- if (Number.isFinite(rpm) && rpm > 0) body.rpm = Math.floor(rpm);
127
- const r = await api(ctx(), "POST", "/admin/providers", body);
128
- console.log(`Added provider ${(r as any).provider.name} (${(r as any).provider.id})`);
129
- });
130
-
131
- provider.command("list").action(async () => {
132
- const r = (await api(ctx(), "GET", "/admin/providers")) as { providers: any[] };
133
- if (!r.providers.length) return console.log("No providers yet. Add one: myapikey provider add <name> ...");
134
- for (const p of r.providers)
135
- console.log(`${p.id} ${p.name} [${p.formats.join(",")}] openai:${p.baseUrlOpenai || "-"} anthropic:${p.baseUrlAnthropic || "-"} key:${p.apiKey}`);
136
- });
137
-
138
- async function resolveProviderId(ref: string): Promise<string> {
139
- const r = (await api(ctx(), "GET", "/admin/providers")) as { providers: any[] };
140
- const byId = r.providers.find((p) => p.id === ref);
141
- if (byId) return byId.id;
142
- const byName = r.providers.filter((p) => p.name === ref);
143
- if (byName.length === 1) return byName[0].id;
144
- if (byName.length > 1) throw new Error(`Multiple providers named '${ref}'; use the id.`);
145
- throw new Error(`No provider matching '${ref}'.`);
146
- }
147
-
148
- provider.command("remove <ref>").description("remove by id or name").action(async (ref: string) => {
149
- const id = await resolveProviderId(ref);
150
- await api(ctx(), "DELETE", `/admin/providers/${id}`);
151
- console.log(`Removed provider ${id}.`);
152
- });
153
-
154
- provider
155
- .command("models <ref>")
156
- .description("discover available models from a backend")
157
- .action(async (ref: string) => {
158
- const id = await resolveProviderId(ref);
159
- const r = (await api(ctx(), "POST", `/admin/providers/${id}/discover`)) as { models: string[] };
160
- if (!r.models.length) return console.log("No models discovered (check base url / key / formats).");
161
- for (const m of r.models) console.log(m);
162
- });
163
-
164
- // ---------------------------------------------------------------------------
165
- // model
166
- // ---------------------------------------------------------------------------
167
- const model = program.command("model").description("manage the routing table");
168
-
169
- /** Shared --format flag: every model mutation acts on one routing slot. */
170
- function fmtOption() {
171
- return new Option("-f, --format <fmt>", "routing slot to act on")
172
- .choices(["openai", "anthropic", "responses"])
173
- .makeOptionMandatory();
174
- }
175
-
176
- model.command("list").action(async () => {
177
- const r = (await api(ctx(), "GET", "/admin/models")) as { models: any[] };
178
- if (!r.models.length) return console.log("No models configured.");
179
- const fmts = ["openai", "anthropic", "responses"] as const;
180
- for (const m of r.models) {
181
- console.log(m.name);
182
- for (const f of fmts) {
183
- const fe = m[f];
184
- const chain = fe.providers.map((p: any) => `${p.model ? `${p.name}→${p.model}` : p.name}${p.thinking ? ` thinking=${p.thinking}` : ""}${p.sampling ? ` sampling=${JSON.stringify(p.sampling)}` : ""}`).join(" → ") || "(none)";
185
- console.log(` ${f.padEnd(9)} ${fe.enabled ? "✓" : "·"} ${chain}`);
186
- }
187
- if (m.paceRpm) console.log(` pace ${m.paceRpm}/min (one every ${Math.round(60 / m.paceRpm)}s)`);
188
- }
189
- });
190
-
191
- model
192
- .command("enable <name>")
193
- .addOption(fmtOption())
194
- .option("--via <provider>", "provider id or name to route through")
195
- .action(async (name: string, opts: { format: "openai" | "anthropic"; via?: string }) => {
196
- let providerId: string | undefined;
197
- if (opts.via) providerId = await resolveProviderId(opts.via);
198
- await api(ctx(), "POST", "/admin/models", { name, format: opts.format, providers: providerId ? [providerId] : [] });
199
- console.log(
200
- `Enabled ${name} [${opts.format}]${providerId ? ` via ${opts.via}` : ""}. Add fallbacks: myapikey model add-provider ${name} <provider> --format ${opts.format}`,
201
- );
202
- });
203
-
204
- model
205
- .command("disable <name>")
206
- .addOption(fmtOption())
207
- .action(async (name: string, opts: { format: "openai" | "anthropic" }) => {
208
- await api(ctx(), "POST", `/admin/models/${encodeURIComponent(name)}/disable`, { format: opts.format });
209
- console.log(`Disabled ${name} [${opts.format}].`);
210
- });
211
-
212
- model
213
- .command("add-provider <name> <ref>")
214
- .addOption(fmtOption())
215
- .action(async (name: string, ref: string, opts: { format: "openai" | "anthropic" }) => {
216
- const providerId = await resolveProviderId(ref);
217
- await api(ctx(), "POST", `/admin/models/${encodeURIComponent(name)}/providers`, { format: opts.format, providerId });
218
- console.log(`Added ${ref} to ${name} [${opts.format}].`);
219
- });
220
-
221
- model
222
- .command("remove-provider <name> <ref>")
223
- .addOption(fmtOption())
224
- .action(async (name: string, ref: string, opts: { format: "openai" | "anthropic" }) => {
225
- const providerId = await resolveProviderId(ref);
226
- await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}/providers/${providerId}?format=${opts.format}`);
227
- console.log(`Removed ${ref} from ${name} [${opts.format}].`);
228
- });
229
-
230
- model
231
- .command("prioritize <name> <refs...>")
232
- .description("reorder sources on a route (left = primary); list every source on the route, in order")
233
- .addOption(fmtOption())
234
- .action(async (name: string, refs: string[], opts: { format: "openai" | "anthropic" }) => {
235
- // The server reorders EXISTING slots (a duplicate id can occupy several), so
236
- // each ref maps onto the chain left-to-right — a second ref with the same id
237
- // consumes the next slot for that id. The refs must cover the whole route
238
- // (the resulting indices are a permutation of [0..n-1]).
239
- const wanted: string[] = [];
240
- for (const ref of refs) wanted.push(await resolveProviderId(ref));
241
- const r = (await api(ctx(), "GET", "/admin/models")) as { models: any[] };
242
- const entry = (r.models as any[]).find((m) => m.name === name);
243
- if (!entry) throw new Error(`No model '${name}'.`);
244
- const chain: { id: string }[] = entry[opts.format]?.providers ?? [];
245
- const used = new Set<number>();
246
- const order: number[] = [];
247
- for (const id of wanted) {
248
- const idx = chain.findIndex((s, i) => s.id === id && !used.has(i));
249
- if (idx === -1)
250
- throw new Error(`'${name}' [${opts.format}] has no remaining slot for that source — list every source on the route exactly once.`);
251
- used.add(idx);
252
- order.push(idx);
253
- }
254
- await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/priority`, { format: opts.format, order });
255
- console.log(`Priority for ${name} [${opts.format}]: ${refs.join(" → ")}`);
256
- });
257
-
258
- model
259
- .command("pace <name> [rpm]")
260
- .description("set/clear the per-model even-pacing limit (requests/min, spread one every 60/rpm s; 0 = unlimited)")
261
- .action(async (name: string, rpmRaw: string) => {
262
- const rpm = Math.floor(Number(rpmRaw ?? 0)) || 0;
263
- const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/pace`, { rpm })) as { paceRpm: number };
264
- console.log(r.paceRpm ? `Pacing ${name} at ${r.paceRpm}/min (one every ${Math.round(60 / r.paceRpm)}s).` : `Pacing cleared for ${name} (unlimited).`);
265
- });
266
-
267
- model
268
- .command("thinking <name> <index> [value]")
269
- .description("set/clear the default thinking level of one chain slot (effort on openai/responses, budget tokens on anthropic; overrides the request's own setting; empty = clear)")
270
- .addOption(fmtOption())
271
- .action(async (name: string, indexRaw: string, value: string | undefined, opts: { format: "openai" | "anthropic" | "responses" }) => {
272
- const index = Number(indexRaw);
273
- const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/thinking`, { format: opts.format, index, thinking: value ?? "" })) as { thinking?: string };
274
- console.log(
275
- r.thinking
276
- ? `Default thinking for ${name} [${opts.format}] slot ${index}: ${r.thinking}.`
277
- : `Default thinking cleared for ${name} [${opts.format}] slot ${index}.`,
278
- );
279
- });
280
-
281
- model
282
- .command("sampling <name> <index> [json]")
283
- .description(`set/clear the default sampling parameters of one chain slot as a JSON object (e.g. '{"temperature":0.2,"top_p":0.9}'; each field overrides the request's own value; empty = clear)`)
284
- .addOption(fmtOption())
285
- .action(async (name: string, indexRaw: string, value: string | undefined, opts: { format: "openai" | "anthropic" | "responses" }) => {
286
- const index = Number(indexRaw);
287
- let sampling: Record<string, unknown> | undefined;
288
- if (value !== undefined && value.trim()) {
289
- try {
290
- sampling = JSON.parse(value) as Record<string, unknown>;
291
- } catch {
292
- throw new Error(`sampling must be a JSON object, e.g. '{"temperature":0.2}'`);
293
- }
294
- }
295
- const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/sampling`, { format: opts.format, index, sampling })) as { sampling?: Record<string, unknown> };
296
- console.log(
297
- r.sampling
298
- ? `Default sampling for ${name} [${opts.format}] slot ${index}: ${JSON.stringify(r.sampling)}.`
299
- : `Default sampling cleared for ${name} [${opts.format}] slot ${index}.`,
300
- );
301
- });
302
-
303
- model.command("remove <name>").description("remove a model entirely (both formats)").action(async (name: string) => {
304
- await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}`);
305
- console.log(`Removed ${name}.`);
306
- });
307
-
308
- model
309
- .command("debug <name> [action] [index]")
310
- .description("debug capture: on|off toggles recording of the last 50 actual requests/responses (failed attempts are ALWAYS recorded in a global net, last 50); show lists everything (add an index to dump one in full)")
311
- .action(async (name: string, action = "show", indexRaw?: string) => {
312
- const path = `/admin/models/${encodeURIComponent(name)}/debug`;
313
- if (action === "on" || action === "off") {
314
- const r = (await api(ctx(), "PUT", path, { enabled: action === "on" })) as { enabled: boolean };
315
- console.log(
316
- r.enabled
317
- ? `Debug capture ON for ${name} — the last 50 upstream attempts (request + response) are recorded in memory; turn off to clear.`
318
- : `Debug capture OFF for ${name}; captured content cleared (auto-recorded failures stay until they age out of the net).`,
319
- );
320
- return;
321
- }
322
- if (action !== "show") throw new Error(`Unknown action '${action}' (use on | off | show).`);
323
- const r = (await api(ctx(), "GET", path)) as { enabled: boolean; captures: any[]; failures: any[] };
324
- if (!r.enabled) console.log(`Debug capture is OFF for ${name} (failed calls are still auto-recorded).`);
325
- const isFail = (c: any) => c.status >= 400 || c.status === 0;
326
- // One newest-first timeline; a failure made while the switch was on sits
327
- // in both server buffers — show it once, from the net, tagged AUTO.
328
- const rows = [
329
- ...(r.failures ?? []).map((c) => ({ c, auto: true })),
330
- ...(r.captures ?? []).filter((c) => !isFail(c)).map((c) => ({ c, auto: false })),
331
- ].sort((a, b) => b.c.ts - a.c.ts);
332
- if (indexRaw !== undefined) {
333
- const row = rows[Number(indexRaw) - 1];
334
- if (!row) throw new Error(`No capture #${indexRaw} (list is newest-first, ${rows.length} recorded).`);
335
- const c = row.c;
336
- console.log(`#${Number(indexRaw)}${row.auto ? " (auto)" : ""} ${new Date(c.ts).toLocaleTimeString("en-GB", { hour12: false })} ${c.provider} [${c.format}] status=${c.status} ${c.ms}ms${c.upstreamModel ? ` model=${c.upstreamModel}` : ""}${c.error ? ` error=${c.error}` : ""}${c.truncated ? " (truncated)" : ""}`);
337
- console.log("\n--- request (forwarded verbatim) ---");
338
- console.log(prettyJson(c.request));
339
- if (c.response !== undefined) {
340
- console.log("\n--- response (upstream) ---");
341
- console.log(prettyJson(c.response));
342
- }
343
- return;
344
- }
345
- if (!rows.length) return console.log("Nothing recorded yet — call the model, then `show` again (or pass an index to dump one).");
346
- console.log(`${name}: ${rows.length} recorded (newest first; AUTO = failed attempt, always recorded)`);
347
- for (let i = 0; i < rows.length; i++) {
348
- const { c, auto } = rows[i];
349
- console.log(
350
- ` #${i + 1}${auto ? " AUTO" : " "} ${new Date(c.ts).toLocaleTimeString("en-GB", { hour12: false })} ${c.provider} [${c.format}] status=${c.status} ${c.ms}ms ${fmtBytes(c.request)} req / ${fmtBytes(c.response ?? "")} resp${c.upstreamModel ? ` model=${c.upstreamModel}` : ""}${c.error ? ` ${c.error}` : ""}`,
351
- );
352
- }
353
- console.log("\nDump one: myapikey model debug <name> show <index>");
354
- });
355
-
356
- /** Pretty-print when the text parses as JSON (a forwarded request body, a JSON
357
- * response); raw text (e.g. an SSE stream) goes through untouched. */
358
- function prettyJson(s: string): string {
359
- try {
360
- return JSON.stringify(JSON.parse(s), null, 2);
361
- } catch {
362
- return s;
363
- }
364
- }
365
-
366
- function fmtBytes(s: string): string {
367
- const n = Buffer.byteLength(s, "utf8");
368
- return n >= 1024 ? `${(n / 1024).toFixed(1)}KB` : `${n}B`;
369
- }
370
-
371
- // ---------------------------------------------------------------------------
372
- // call
373
- // ---------------------------------------------------------------------------
374
- program
375
- .command("call <model> [prompt...]")
376
- .description("quick test a model through the gateway")
377
- .action(async (modelName: string, promptParts: string[]) => {
378
- const prompt = promptParts.join(" ").trim();
379
- const input = prompt || (await readStdin());
380
- if (!input) return console.log("Provide a prompt: myapikey call <model> hello");
381
- const r = (await api(ctx(), "POST", "/openai-chat/v1/chat/completions", {
382
- model: modelName,
383
- messages: [{ role: "user", content: input }],
384
- })) as any;
385
- const content = r?.choices?.[0]?.message?.content;
386
- console.log(typeof content === "string" ? content : JSON.stringify(content ?? r));
387
- });
388
-
389
- function readStdin(): Promise<string> {
390
- return new Promise((res) => {
391
- let data = "";
392
- if (process.stdin.isTTY) return res("");
393
- process.stdin.setEncoding("utf8");
394
- process.stdin.on("data", (c) => (data += c));
395
- process.stdin.on("end", () => res(data));
396
- });
397
- }
398
-
399
- // ---------------------------------------------------------------------------
400
- program.parseAsync().catch((e) => {
401
- if (e instanceof ApiError) console.error(`Error ${e.status}: ${e.message}`);
402
- else console.error(e?.message ?? String(e));
403
- process.exit(1);
404
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes