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 +36 -93
- package/README.zh-CN.md +38 -95
- package/package.json +25 -16
- package/{packages/core/src → src}/server/auth.ts +2 -2
- package/src/server/index.ts +49 -0
- package/{packages/core/src → src}/server/proxy.ts +5 -8
- package/packages/core/src/cli/client.ts +0 -69
- package/packages/core/src/cli/config.ts +0 -64
- package/packages/core/src/cli/index.ts +0 -404
- /package/{packages/web/dist → dist}/assets/index-CIGEPkrP.js +0 -0
- /package/{packages/web/dist → dist}/assets/index-CWGc5RaE.css +0 -0
- /package/{packages/web/dist → dist}/index.html +0 -0
- /package/{packages/core/src → src}/server/admin.ts +0 -0
- /package/{packages/core/src → src}/server/app.ts +0 -0
- /package/{packages/core/src → src}/server/logger.ts +0 -0
- /package/{packages/core/src → src}/server/store.ts +0 -0
- /package/{packages/core/src → src}/server/tokens.ts +0 -0
- /package/{packages/core/src → src}/shared/config.ts +0 -0
- /package/{packages/core/src → src}/shared/types.ts +0 -0
package/README.md
CHANGED
|
@@ -2,133 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/myapikey)
|
|
4
4
|
[](./LICENSE)
|
|
5
|
-
[](#quick-start)
|
|
6
6
|
[](#development)
|
|
7
7
|
|
|
8
8
|
English | [简体中文](README.zh-CN.md)
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
##
|
|
12
|
+
## Why use it
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
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
|
-
|
|
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
|
|
24
|
+
npx myapikey
|
|
38
25
|
```
|
|
39
26
|
|
|
40
|
-
|
|
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.
|
|
43
|
-
2.
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
Data defaults to `~/.myapikey`; override it with `--data-dir` or `MYAPIKEY_DATA_DIR`.
|
|
85
59
|
|
|
86
|
-
|
|
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
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
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
|
-
|
|
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
|
|
124
|
-
npm run dev
|
|
125
|
-
npm run dev:web
|
|
126
|
-
npm test
|
|
127
|
-
npm run typecheck
|
|
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
|
[](https://www.npmjs.com/package/myapikey)
|
|
4
4
|
[](./LICENSE)
|
|
5
|
-
[](
|
|
5
|
+
[](#quick-start)
|
|
6
|
+
[](#development)
|
|
7
7
|
|
|
8
8
|
[English](README.md) | 简体中文
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
轻量自托管 LLM 网关。所有工具只填一个地址和一把网关 key;供应商 key、模型路由、故障转移和调用记录都保存在你自己的服务器上。
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Why use it
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
14
|
+
- **保护云端 key:** 真实的公网供应商 key 只保存在自己的网关里。客户端工具拿到的是本地网关 key,即使工具配置泄露,也不会直接暴露上游账号。
|
|
15
|
+
- **自定义模型接口:** 对工具暴露稳定的模型名。供应商、部署方式或价格变化时,只需要更新映射和路由,客户端配置不用改。
|
|
16
|
+
- **明确多协议:** 显式选择 OpenAI 或 Anthropic 格式并原样转发。你清楚每个工具和后端到底用什么协议,上游新字段也不需要等转换层适配。
|
|
17
|
+
- **轻到敢直接改:** 一个专注的 TypeScript 代码库,无数据库、文件存储。拿过去就能读懂和修改,不用先啃一个过于复杂、不适合个人使用的重型网关。
|
|
18
18
|
|
|
19
|
-
|
|
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
|
|
24
|
+
npx myapikey
|
|
38
25
|
```
|
|
39
26
|
|
|
40
|
-
|
|
27
|
+
打开 `http://localhost:7800`,用首次启动打印的凭据登录(也会保存到 `~/.myapikey/credentials.txt`)。
|
|
41
28
|
|
|
42
|
-
1.
|
|
43
|
-
2.
|
|
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 兼容工具
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|---|---|---|---|
|
|
78
|
-
| 部署 | 自托管,`npx` 一行 | 自托管,较重 | 托管服务 |
|
|
79
|
-
| 费用 | 免费 | 免费 | 按次加价 |
|
|
80
|
-
| 格式 | 逐字节透传 | 翻译 | 翻译 |
|
|
81
|
-
| 上游新参数 | 立即可用 | 等网关适配 | 等平台适配 |
|
|
82
|
-
| 供应商 key | 在你自己机器上 | 在你自己机器上 | 在平台手里 |
|
|
54
|
+
请求通过 `/openai/v1/*` 或 `/anthropic/v1/*` 进入,并保持原格式转发。模型和格式不匹配返回 `404`;调用方自身的 `4xx` 错误原样返回。如果你需要 OpenAI ↔ Anthropic 格式互转,这个网关明确不提供该能力。
|
|
83
55
|
|
|
84
|
-
|
|
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` |
|
|
115
|
-
| `logs.jsonl` | 调用历史(
|
|
116
|
-
| `credentials.txt` |
|
|
117
|
-
|
|
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
|
|
124
|
-
npm run dev
|
|
125
|
-
npm run dev:web
|
|
126
|
-
npm test
|
|
127
|
-
npm run typecheck
|
|
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.
|
|
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
|
|
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": "
|
|
44
|
+
"myapikey": "src/server/index.ts"
|
|
46
45
|
},
|
|
47
46
|
"files": [
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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
|
|
56
|
-
"dev: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
|
|
59
|
-
"typecheck": "tsc
|
|
60
|
-
"build: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
|
-
"
|
|
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
|
|
43
|
-
// password on every run. Production
|
|
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
|
|
File without changes
|
|
File without changes
|