flexgate 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {flexgate-0.3.0 → flexgate-0.4.0}/CLAUDE.md +5 -1
- {flexgate-0.3.0 → flexgate-0.4.0}/PKG-INFO +60 -5
- {flexgate-0.3.0 → flexgate-0.4.0}/README.md +59 -4
- {flexgate-0.3.0 → flexgate-0.4.0}/config.yaml.template +17 -15
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/__init__.py +1 -1
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/cli.py +108 -4
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/config.py +72 -19
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/doctor.py +4 -1
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/migrate.py +21 -2
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/proxy.py +73 -20
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/settings.py +2 -1
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/sync.py +20 -4
- flexgate-0.4.0/flexgate/usage.py +364 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/.github/workflows/release.yml +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/.gitignore +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/__main__.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/guardian.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/healthcheck.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/main.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/router.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/server.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/service.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/flexgate/update.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/pyproject.toml +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/test.png +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/test_multimodal.py +0 -0
- {flexgate-0.3.0 → flexgate-0.4.0}/test_multimodal_gateway.py +0 -0
|
@@ -22,6 +22,7 @@ flexgate service install # install + enable + start systemd u
|
|
|
22
22
|
flexgate service {install|start|stop|restart|reload|status|uninstall}
|
|
23
23
|
flexgate run # foreground/debug only
|
|
24
24
|
flexgate check # provider connectivity diagnostics
|
|
25
|
+
flexgate status # providers, fallback chains, per-key usage, active routes
|
|
25
26
|
|
|
26
27
|
# Config
|
|
27
28
|
flexgate config {init|show|set|path|edit}
|
|
@@ -83,7 +84,8 @@ Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
|
|
|
83
84
|
| `cli.py` | argparse CLI; service lifecycle commands, foreground `run`/`check`, config/settings/sync commands |
|
|
84
85
|
| `config.py` | Pydantic-like dataclasses (`GatewayConfig`, `ProviderConfig`, `RouteConfig`, `ScheduleEntry`), YAML load/save, `TIER_PATTERNS` regex map |
|
|
85
86
|
| `router.py` | `resolve(config, model)` — schedule-first then default routes, first regex match wins |
|
|
86
|
-
| `proxy.py` | `handle_request()` — httpx async proxy, SSE streaming + JSON pass-through |
|
|
87
|
+
| `proxy.py` | `handle_request()` — httpx async proxy, per-key fallback retry loop, SSE streaming + JSON pass-through |
|
|
88
|
+
| `usage.py` | `flexgate status` usage queries — per-platform adapters (MiniMax coding_plan API, Kimi Code usages API, z.ai quota API, LiteLLM `/key/info`) plus a minimal chat probe fallback |
|
|
87
89
|
| `server.py` | Starlette app creation, `POST /v1/messages` endpoint, `SIGUSR1` lifespan reload |
|
|
88
90
|
| `main.py` | Thin bootstrap: load config → create app → run uvicorn |
|
|
89
91
|
| `service.py` | Authoritative systemd user-service install/start/stop/restart/reload/status and legacy PID migration |
|
|
@@ -100,6 +102,8 @@ Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
|
|
|
100
102
|
- **Regex-first routing**: Routes are regex patterns matched against the `model` field in the request body. First match wins. A catch-all `".*"` pattern at the end handles fallback.
|
|
101
103
|
- **Model resolution & `available_models` fallback**: A route may omit `model`; `router.resolve()` then falls back to the provider's first `available_models` entry, so `model_override` handed to the proxy is always a concrete name. `config._parse_routes` rejects routes that omit `model` on a provider with no `available_models` — so adding a provider without models requires an explicit `model` on every route using it.
|
|
102
104
|
- **Proxy rewrite contract** (`proxy.py`): the upstream request gets the provider's `x-api-key` plus a fixed header set, and the JSON `model` field is rewritten only when the route set an override. Streaming responses are forwarded as raw bytes (`aiter_bytes`), never parsed.
|
|
105
|
+
- **Key fallback** (`proxy.py`): a provider's `api_keys` list holds one or more keys for the same upstream (entries are key strings or `{key, note}` mappings; `note` is a free-form label shown by `flexgate status` and in fallback logs). Multiple accounts on one upstream — e.g. several MiniMax subscriptions — belong in ONE provider, not separate ones. On a retryable failure (HTTP 401/402/403/429/500/502/503/529, connect error, or timeout) the request is retried with the next key in order. Streaming requests can only fall back before the upstream returns 200. `_regular_proxy`/`_stream_proxy` return `(response, retryable)`; `handle_request` owns the loop and returns the last error when all keys fail. The legacy `api_key` + `fallback_keys` schema is still parsed (`config._parse_api_keys`); migration v3 → v4 rewrites it to `api_keys`. `ProviderConfig.api_key` remains as a first-key property (getter/setter) for sync/settings merge code.
|
|
106
|
+
- **Usage inspection** (`usage.py`): `flexgate status` maps `base_url` to a platform adapter (`_ADAPTERS`): MiniMax → `{origin}/v1/api/openplatform/coding_plan/remains` (the `*_usage_count` fields are REMAINING counts, an upstream naming quirk), Kimi Code → `{base}/v1/usages` (undocumented, same endpoint as the CLI's `/usage`; reports weekly quota, 5-hour window and parallel limit), z.ai/bigmodel → `{origin}/api/monitor/usage/quota/limit` (undocumented but used by z.ai's own plugin), USTC/LiteLLM → `{origin}/key/info`. Hosts in `_PROBE_ONLY_MARKERS` (e.g. xiaomimimo.com — no key-based usage API) and unknown platforms get the minimal chat probe: `POST /v1/messages` with input `"hi"`, `max_tokens=128`. A failed adapter call also degrades to the probe.
|
|
103
107
|
- **Multimodal degradation**: `MULTIMODAL_MODELS` (currently `{"MiniMax-M3", "glm-4.6v"}`) is the allowlist. Requests carrying image blocks aimed at any other model have images stripped and a `[flexgate]` text note injected into both the outgoing request and the returned response, so non-multimodal backends don't 4xx.
|
|
104
108
|
- **Schedule-based overrides**: Optional time windows (e.g. 22:00-06:00) override default routes. Overnight wrap is supported.
|
|
105
109
|
- **Service-first lifecycle**: `flexgate.service` is the sole persistent runtime. systemd owns restart, boot startup, logs, and process state.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flexgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Flexible local API gateway for Claude Code — route models to different providers
|
|
5
5
|
Project-URL: Homepage, https://github.com/Agony5757/flexible-gateway
|
|
6
6
|
Project-URL: Repository, https://github.com/Agony5757/flexible-gateway
|
|
@@ -154,6 +154,34 @@ Trusted Publisher(repo: `Agony5757/flexible-gateway`,workflow: `release.yml`
|
|
|
154
154
|
|
|
155
155
|
可通过 `--verify-timeout N` 调整每个 provider 的超时时间(默认 15 秒)。
|
|
156
156
|
|
|
157
|
+
### 状态总览与用量查询(`flexgate status` / `flexgate usage`)
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
flexgate status # providers + fallback 链 + 每个 key 的用量 + 当前路由
|
|
161
|
+
flexgate status --no-usage # 跳过用量查询,只看配置
|
|
162
|
+
flexgate status --usage-timeout 30
|
|
163
|
+
flexgate usage # 只看每个 key 的用量/额度(不打印配置和路由)
|
|
164
|
+
flexgate usage --usage-timeout 30
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`flexgate status` 展示当前配置中的所有 provider、每个 provider 的 key 及
|
|
168
|
+
fallback 链、当前生效的路由,并逐一查询**每个 key** 的用量/额度;
|
|
169
|
+
`flexgate usage` 只输出其中的用量部分。
|
|
170
|
+
|
|
171
|
+
各平台的用量查询方式差异很大,flexgate 按 `base_url` 自动选择适配器:
|
|
172
|
+
|
|
173
|
+
| 平台 | 查询方式 | 说明 |
|
|
174
|
+
|------|----------|------|
|
|
175
|
+
| MiniMax(api.minimaxi.com / api.minimax.io) | `GET /v1/api/openplatform/coding_plan/remains`(Bearer 认证,复用 provider key) | 官方 token plan 接口,返回 5 小时窗口和周窗口的**剩余**次数(注意响应里 `*_usage_count` 字段实际是剩余量) |
|
|
176
|
+
| Kimi Code(api.kimi.com) | `GET {base}/v1/usages`(Bearer 认证,复用 provider key) | 未公开文档、与 Kimi Code CLI `/usage` 相同的接口,返回周配额、5 小时滚动窗口剩余量和并发上限 |
|
|
177
|
+
| z.ai / 智谱(api.z.ai / open.bigmodel.cn) | `GET /api/monitor/usage/quota/limit`(Authorization 直接带 key) | 未公开文档、但 z.ai 官方 coding 插件在用的接口,返回各窗口已用百分比与重置时间 |
|
|
178
|
+
| USTC(api.llm.ustc.edu.cn,LiteLLM) | `GET /key/info`(Bearer 认证) | LiteLLM proxy 自带的 key 信息接口,返回 spend / max_budget / 限速等 |
|
|
179
|
+
| 小米 MiMo(token-plan-cn.xiaomimimo.com) | **无 key 可用的官方接口**(控制台内部接口需要浏览器 cookie,不采用) | 退化为 minimal probe |
|
|
180
|
+
| 其他/未知平台 | minimal chat probe | 发一条输入 `"hi"`、`max_tokens=128` 的最小 `/v1/messages` 请求,验证 key 是否还能正常服务(会消耗极少量额度) |
|
|
181
|
+
|
|
182
|
+
如果某平台的专用接口调用失败,flexgate 会自动退化为 minimal probe 再试一次。
|
|
183
|
+
用量查询接口多为平台内部接口,可能随时变动;查询结果仅供参考。
|
|
184
|
+
|
|
157
185
|
### 前台调试与连通性检查
|
|
158
186
|
|
|
159
187
|
`run` / `check` 是独立的顶层调试命令,不属于持久化服务模式:
|
|
@@ -220,7 +248,7 @@ Flexgate config — ~/.flexgate/config.yaml
|
|
|
220
248
|
|
|
221
249
|
- 方向键选中某个 tier(opus/sonnet/haiku),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
|
|
222
250
|
- model 列表包含:`available_models` 中的各个模型、「使用 provider 默认(首个可用模型,不写死 model)」、以及「自定义模型…」(手动输入)。
|
|
223
|
-
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1
|
|
251
|
+
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1 热重载,**无需重启即生效**),按 `q` 退出(有未保存改动时会询问 "Config changed — activate now?":选 Yes 立即保存并热重载生效,选 No 放弃改动);子菜单中按 `Esc`/`←` 返回上一级。
|
|
224
252
|
- 需要交互式终端(TTY);非交互场景请改用 `flexgate config set`。
|
|
225
253
|
|
|
226
254
|
### Settings 管理
|
|
@@ -261,10 +289,16 @@ server:
|
|
|
261
289
|
providers:
|
|
262
290
|
zai:
|
|
263
291
|
base_url: "https://api.z.ai/api/anthropic"
|
|
264
|
-
|
|
292
|
+
api_keys:
|
|
293
|
+
- "your-zai-api-key"
|
|
265
294
|
minimax:
|
|
266
295
|
base_url: "https://api.minimaxi.com/anthropic"
|
|
267
|
-
|
|
296
|
+
api_keys: # 同一上游可配多个 key,互为 fallback
|
|
297
|
+
- key: "your-minimax-api-key"
|
|
298
|
+
note: "主账号" # 可选备注,存在配置里,status/日志中显示
|
|
299
|
+
- key: "your-minimax-api-key-2"
|
|
300
|
+
note: "备用账号"
|
|
301
|
+
- "your-minimax-api-key-3" # 纯字符串写法(无备注)
|
|
268
302
|
|
|
269
303
|
claude_settings:
|
|
270
304
|
default_opus_model: "claude-opus-4-7"
|
|
@@ -303,7 +337,8 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
303
337
|
|------|------|
|
|
304
338
|
| `server.host/port` | 网关监听地址 |
|
|
305
339
|
| `providers.<name>.base_url` | Provider 的 API 地址 |
|
|
306
|
-
| `providers.<name>.
|
|
340
|
+
| `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
|
|
341
|
+
| `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
|
|
307
342
|
| `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
|
|
308
343
|
| `routes[].pattern` | 正则匹配请求中的 model 字段 |
|
|
309
344
|
| `routes[].provider` | 路由到的 provider 名称 |
|
|
@@ -312,6 +347,26 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
312
347
|
| `schedule[].start/end` | 时间窗口(HH:MM 格式,支持跨夜如 22:00-06:00) |
|
|
313
348
|
| `schedule[].routes` | 该时间窗口内生效的路由(格式同 `routes`) |
|
|
314
349
|
|
|
350
|
+
### Key fallback
|
|
351
|
+
|
|
352
|
+
同一上游有多个账号/key 时(例如多个 MiniMax 订阅),在 `api_keys` 里配
|
|
353
|
+
多个 key,而不是拆成多个 provider。每项可以是纯 key 字符串,也可以写成
|
|
354
|
+
`{key, note}` 加一个存在配置里的备注(`flexgate status` 和 fallback 日志
|
|
355
|
+
都会显示 note,方便分辨是哪个账号的 key):
|
|
356
|
+
|
|
357
|
+
- 请求先走第一个 key;失败时按列表顺序自动重试后续 key,直到某个 key
|
|
358
|
+
成功或返回不可重试的错误。
|
|
359
|
+
- 触发切换的条件:HTTP **401 / 402 / 403 / 429 / 500 / 502 / 503 / 529**
|
|
360
|
+
(key 失效、余额/额度耗尽、限流、平台过载)以及连接错误、超时。
|
|
361
|
+
其他 4xx(如 400 请求格式错误)不会触发切换。
|
|
362
|
+
- 流式请求只有在上游返回非 200 状态码之前才能切换 key;一旦开始吐
|
|
363
|
+
token,响应已提交,无法再 fallback。
|
|
364
|
+
- 每次切换都会在服务日志中留下记录(key 只显示前后各 4 位)。
|
|
365
|
+
- 用 `flexgate status` 可以查看每个 provider 的 fallback 链和每个 key 的
|
|
366
|
+
实时用量。
|
|
367
|
+
- 旧版 `api_key` + `fallback_keys` 写法仍然兼容,`flexgate update` 会
|
|
368
|
+
自动迁移为 `api_keys` 列表(config_version 3 → 4)。
|
|
369
|
+
|
|
315
370
|
## Settings Import
|
|
316
371
|
|
|
317
372
|
`flexgate settings import` 会扫描 `~/.claude/settings.json*`,从每个文件中提取 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN`,自动写入 config.yaml 的 providers 部分。
|
|
@@ -137,6 +137,34 @@ Trusted Publisher(repo: `Agony5757/flexible-gateway`,workflow: `release.yml`
|
|
|
137
137
|
|
|
138
138
|
可通过 `--verify-timeout N` 调整每个 provider 的超时时间(默认 15 秒)。
|
|
139
139
|
|
|
140
|
+
### 状态总览与用量查询(`flexgate status` / `flexgate usage`)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
flexgate status # providers + fallback 链 + 每个 key 的用量 + 当前路由
|
|
144
|
+
flexgate status --no-usage # 跳过用量查询,只看配置
|
|
145
|
+
flexgate status --usage-timeout 30
|
|
146
|
+
flexgate usage # 只看每个 key 的用量/额度(不打印配置和路由)
|
|
147
|
+
flexgate usage --usage-timeout 30
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`flexgate status` 展示当前配置中的所有 provider、每个 provider 的 key 及
|
|
151
|
+
fallback 链、当前生效的路由,并逐一查询**每个 key** 的用量/额度;
|
|
152
|
+
`flexgate usage` 只输出其中的用量部分。
|
|
153
|
+
|
|
154
|
+
各平台的用量查询方式差异很大,flexgate 按 `base_url` 自动选择适配器:
|
|
155
|
+
|
|
156
|
+
| 平台 | 查询方式 | 说明 |
|
|
157
|
+
|------|----------|------|
|
|
158
|
+
| MiniMax(api.minimaxi.com / api.minimax.io) | `GET /v1/api/openplatform/coding_plan/remains`(Bearer 认证,复用 provider key) | 官方 token plan 接口,返回 5 小时窗口和周窗口的**剩余**次数(注意响应里 `*_usage_count` 字段实际是剩余量) |
|
|
159
|
+
| Kimi Code(api.kimi.com) | `GET {base}/v1/usages`(Bearer 认证,复用 provider key) | 未公开文档、与 Kimi Code CLI `/usage` 相同的接口,返回周配额、5 小时滚动窗口剩余量和并发上限 |
|
|
160
|
+
| z.ai / 智谱(api.z.ai / open.bigmodel.cn) | `GET /api/monitor/usage/quota/limit`(Authorization 直接带 key) | 未公开文档、但 z.ai 官方 coding 插件在用的接口,返回各窗口已用百分比与重置时间 |
|
|
161
|
+
| USTC(api.llm.ustc.edu.cn,LiteLLM) | `GET /key/info`(Bearer 认证) | LiteLLM proxy 自带的 key 信息接口,返回 spend / max_budget / 限速等 |
|
|
162
|
+
| 小米 MiMo(token-plan-cn.xiaomimimo.com) | **无 key 可用的官方接口**(控制台内部接口需要浏览器 cookie,不采用) | 退化为 minimal probe |
|
|
163
|
+
| 其他/未知平台 | minimal chat probe | 发一条输入 `"hi"`、`max_tokens=128` 的最小 `/v1/messages` 请求,验证 key 是否还能正常服务(会消耗极少量额度) |
|
|
164
|
+
|
|
165
|
+
如果某平台的专用接口调用失败,flexgate 会自动退化为 minimal probe 再试一次。
|
|
166
|
+
用量查询接口多为平台内部接口,可能随时变动;查询结果仅供参考。
|
|
167
|
+
|
|
140
168
|
### 前台调试与连通性检查
|
|
141
169
|
|
|
142
170
|
`run` / `check` 是独立的顶层调试命令,不属于持久化服务模式:
|
|
@@ -203,7 +231,7 @@ Flexgate config — ~/.flexgate/config.yaml
|
|
|
203
231
|
|
|
204
232
|
- 方向键选中某个 tier(opus/sonnet/haiku),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
|
|
205
233
|
- model 列表包含:`available_models` 中的各个模型、「使用 provider 默认(首个可用模型,不写死 model)」、以及「自定义模型…」(手动输入)。
|
|
206
|
-
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1
|
|
234
|
+
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1 热重载,**无需重启即生效**),按 `q` 退出(有未保存改动时会询问 "Config changed — activate now?":选 Yes 立即保存并热重载生效,选 No 放弃改动);子菜单中按 `Esc`/`←` 返回上一级。
|
|
207
235
|
- 需要交互式终端(TTY);非交互场景请改用 `flexgate config set`。
|
|
208
236
|
|
|
209
237
|
### Settings 管理
|
|
@@ -244,10 +272,16 @@ server:
|
|
|
244
272
|
providers:
|
|
245
273
|
zai:
|
|
246
274
|
base_url: "https://api.z.ai/api/anthropic"
|
|
247
|
-
|
|
275
|
+
api_keys:
|
|
276
|
+
- "your-zai-api-key"
|
|
248
277
|
minimax:
|
|
249
278
|
base_url: "https://api.minimaxi.com/anthropic"
|
|
250
|
-
|
|
279
|
+
api_keys: # 同一上游可配多个 key,互为 fallback
|
|
280
|
+
- key: "your-minimax-api-key"
|
|
281
|
+
note: "主账号" # 可选备注,存在配置里,status/日志中显示
|
|
282
|
+
- key: "your-minimax-api-key-2"
|
|
283
|
+
note: "备用账号"
|
|
284
|
+
- "your-minimax-api-key-3" # 纯字符串写法(无备注)
|
|
251
285
|
|
|
252
286
|
claude_settings:
|
|
253
287
|
default_opus_model: "claude-opus-4-7"
|
|
@@ -286,7 +320,8 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
286
320
|
|------|------|
|
|
287
321
|
| `server.host/port` | 网关监听地址 |
|
|
288
322
|
| `providers.<name>.base_url` | Provider 的 API 地址 |
|
|
289
|
-
| `providers.<name>.
|
|
323
|
+
| `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
|
|
324
|
+
| `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
|
|
290
325
|
| `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
|
|
291
326
|
| `routes[].pattern` | 正则匹配请求中的 model 字段 |
|
|
292
327
|
| `routes[].provider` | 路由到的 provider 名称 |
|
|
@@ -295,6 +330,26 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
295
330
|
| `schedule[].start/end` | 时间窗口(HH:MM 格式,支持跨夜如 22:00-06:00) |
|
|
296
331
|
| `schedule[].routes` | 该时间窗口内生效的路由(格式同 `routes`) |
|
|
297
332
|
|
|
333
|
+
### Key fallback
|
|
334
|
+
|
|
335
|
+
同一上游有多个账号/key 时(例如多个 MiniMax 订阅),在 `api_keys` 里配
|
|
336
|
+
多个 key,而不是拆成多个 provider。每项可以是纯 key 字符串,也可以写成
|
|
337
|
+
`{key, note}` 加一个存在配置里的备注(`flexgate status` 和 fallback 日志
|
|
338
|
+
都会显示 note,方便分辨是哪个账号的 key):
|
|
339
|
+
|
|
340
|
+
- 请求先走第一个 key;失败时按列表顺序自动重试后续 key,直到某个 key
|
|
341
|
+
成功或返回不可重试的错误。
|
|
342
|
+
- 触发切换的条件:HTTP **401 / 402 / 403 / 429 / 500 / 502 / 503 / 529**
|
|
343
|
+
(key 失效、余额/额度耗尽、限流、平台过载)以及连接错误、超时。
|
|
344
|
+
其他 4xx(如 400 请求格式错误)不会触发切换。
|
|
345
|
+
- 流式请求只有在上游返回非 200 状态码之前才能切换 key;一旦开始吐
|
|
346
|
+
token,响应已提交,无法再 fallback。
|
|
347
|
+
- 每次切换都会在服务日志中留下记录(key 只显示前后各 4 位)。
|
|
348
|
+
- 用 `flexgate status` 可以查看每个 provider 的 fallback 链和每个 key 的
|
|
349
|
+
实时用量。
|
|
350
|
+
- 旧版 `api_key` + `fallback_keys` 写法仍然兼容,`flexgate update` 会
|
|
351
|
+
自动迁移为 `api_keys` 列表(config_version 3 → 4)。
|
|
352
|
+
|
|
298
353
|
## Settings Import
|
|
299
354
|
|
|
300
355
|
`flexgate settings import` 会扫描 `~/.claude/settings.json*`,从每个文件中提取 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN`,自动写入 config.yaml 的 providers 部分。
|
|
@@ -3,37 +3,39 @@ server:
|
|
|
3
3
|
port: 8765
|
|
4
4
|
|
|
5
5
|
# 每个 provider 必须声明 available_models,首个条目作为路由省略 model 时的回退模型。
|
|
6
|
-
#
|
|
6
|
+
# api_keys:同一上游的一个或多个 API key。每项可以是纯 key 字符串,也可以
|
|
7
|
+
# 写成 {key, note},note 是存在配置里的备注(flexgate status 和 fallback
|
|
8
|
+
# 日志都会显示)。某个 key 遇到 401/402/403/429/5xx/529 或连接错误(如额度
|
|
9
|
+
# 用尽、平台过载)时,按顺序自动切换到下一个 key 重试。同一上游的多个账号
|
|
10
|
+
# 应这样配置,而不是拆成多个 provider。
|
|
7
11
|
providers:
|
|
8
12
|
minimax:
|
|
9
13
|
base_url: "https://api.minimaxi.com/anthropic"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
available_models:
|
|
17
|
-
- "MiniMax-M3"
|
|
18
|
-
minimax-ywj:
|
|
19
|
-
base_url: "https://api.minimaxi.com/anthropic"
|
|
20
|
-
api_key: "your-minimax-ywj-api-key"
|
|
14
|
+
api_keys:
|
|
15
|
+
- key: "your-minimax-api-key"
|
|
16
|
+
note: "主账号"
|
|
17
|
+
- key: "your-minimax-api-key-2"
|
|
18
|
+
note: "备用账号"
|
|
19
|
+
- "your-minimax-api-key-3" # 纯字符串写法(无备注)
|
|
21
20
|
available_models:
|
|
22
21
|
- "MiniMax-M3"
|
|
23
22
|
zai:
|
|
24
23
|
base_url: "https://api.z.ai/api/anthropic"
|
|
25
|
-
|
|
24
|
+
api_keys:
|
|
25
|
+
- "your-zai-api-key"
|
|
26
26
|
available_models:
|
|
27
27
|
- "glm-5.3" # 路由省略 model 时的回退模型
|
|
28
28
|
- "glm-4.6v"
|
|
29
29
|
xiaomi:
|
|
30
30
|
base_url: "https://token-plan-cn.xiaomimimo.com/anthropic"
|
|
31
|
-
|
|
31
|
+
api_keys:
|
|
32
|
+
- "your-xiaomi-api-key"
|
|
32
33
|
available_models:
|
|
33
34
|
- "mimo-v2.5-pro"
|
|
34
35
|
ustc:
|
|
35
36
|
base_url: "https://api.llm.ustc.edu.cn"
|
|
36
|
-
|
|
37
|
+
api_keys:
|
|
38
|
+
- "your-ustc-api-key"
|
|
37
39
|
available_models:
|
|
38
40
|
- "deepseek-v4-pro"
|
|
39
41
|
|
|
@@ -85,6 +85,73 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
85
85
|
print(f"\nAll {len(results)} target(s) OK.")
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
# ── status (providers / fallback / usage / routes) ─────────────────
|
|
89
|
+
|
|
90
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
91
|
+
config_path = args.config
|
|
92
|
+
if not os.path.exists(config_path):
|
|
93
|
+
print(f"Config not found: {config_path}")
|
|
94
|
+
print("Run 'flexgate config init' to create one.")
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
config = load_config(config_path)
|
|
98
|
+
|
|
99
|
+
print(f"flexgate {__version__}")
|
|
100
|
+
print(f"Config: {os.path.abspath(config_path)}")
|
|
101
|
+
print(f"Server: {config.server.host}:{config.server.port}")
|
|
102
|
+
|
|
103
|
+
print(f"\nProviders:")
|
|
104
|
+
for name, prov in config.providers.items():
|
|
105
|
+
keys = prov.api_keys
|
|
106
|
+
key_info = f"key: {_mask_key(keys[0].key)}"
|
|
107
|
+
if keys[0].note:
|
|
108
|
+
key_info += f" [{keys[0].note}]"
|
|
109
|
+
if len(keys) > 1:
|
|
110
|
+
key_info += f" (+{len(keys) - 1} fallback key(s))"
|
|
111
|
+
models = ", ".join(prov.available_models) if prov.available_models else "(no models listed)"
|
|
112
|
+
print(f" {name:16s} {prov.base_url}")
|
|
113
|
+
print(f" {'':16s} {key_info}")
|
|
114
|
+
print(f" {'':16s} models: {models}")
|
|
115
|
+
if len(keys) > 1:
|
|
116
|
+
chain = " → ".join(
|
|
117
|
+
f"#{i + 1} {_mask_key(k.key)}" + (f"[{k.note}]" if k.note else "")
|
|
118
|
+
for i, k in enumerate(keys)
|
|
119
|
+
)
|
|
120
|
+
print(f" {'':16s} fallback: {chain}")
|
|
121
|
+
|
|
122
|
+
_print_active_routes(config)
|
|
123
|
+
|
|
124
|
+
if getattr(args, "no_usage", False):
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
_print_usage(config, getattr(args, "usage_timeout", 15.0))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_usage(args: argparse.Namespace) -> None:
|
|
131
|
+
config_path = args.config
|
|
132
|
+
if not os.path.exists(config_path):
|
|
133
|
+
print(f"Config not found: {config_path}")
|
|
134
|
+
print("Run 'flexgate config init' to create one.")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
config = load_config(config_path)
|
|
138
|
+
_print_usage(config, getattr(args, "usage_timeout", 15.0))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _print_usage(config, timeout: float) -> None:
|
|
142
|
+
from flexgate.usage import run_usage_check
|
|
143
|
+
|
|
144
|
+
print(f"\nUsage (timeout {timeout:g}s per key):")
|
|
145
|
+
usage = run_usage_check(config, timeout=timeout)
|
|
146
|
+
for name, results in usage.items():
|
|
147
|
+
print(f" {name}:")
|
|
148
|
+
for r in results:
|
|
149
|
+
marker = "✓" if r.ok else "✗"
|
|
150
|
+
print(f" {marker} {r.key_label} [{r.method}]")
|
|
151
|
+
for line in r.lines:
|
|
152
|
+
print(f" {line}")
|
|
153
|
+
|
|
154
|
+
|
|
88
155
|
# ── settings subcommands ────────────────────────────────────────────
|
|
89
156
|
|
|
90
157
|
def cmd_settings_import(args: argparse.Namespace) -> None:
|
|
@@ -197,7 +264,10 @@ def cmd_config_show(args: argparse.Namespace) -> None:
|
|
|
197
264
|
|
|
198
265
|
print(f"\nProviders:")
|
|
199
266
|
for name, prov in config.providers.items():
|
|
200
|
-
print(f" {name:16s} {prov.base_url}
|
|
267
|
+
print(f" {name:16s} {prov.base_url}")
|
|
268
|
+
for i, k in enumerate(prov.api_keys):
|
|
269
|
+
note = f" [{k.note}]" if k.note else ""
|
|
270
|
+
print(f" {'':16s} key #{i + 1}: {_mask_key(k.key)}{note}")
|
|
201
271
|
|
|
202
272
|
print(f"\nRoutes (default):")
|
|
203
273
|
_print_route_table(config.routes)
|
|
@@ -448,12 +518,12 @@ def _text_input(stdscr, prompt: str) -> str | None:
|
|
|
448
518
|
return text or None
|
|
449
519
|
|
|
450
520
|
|
|
451
|
-
def _confirm(stdscr, question: str):
|
|
521
|
+
def _confirm(stdscr, question: str, yes_label: str = "Yes, save changes", no_label: str = "No, discard changes"):
|
|
452
522
|
"""Yes/No prompt. Returns True, False, or None (cancel)."""
|
|
453
523
|
sel = _menu_select(
|
|
454
524
|
stdscr,
|
|
455
525
|
[question, ""],
|
|
456
|
-
[(
|
|
526
|
+
[(yes_label, True), (no_label, False)],
|
|
457
527
|
index=0,
|
|
458
528
|
)
|
|
459
529
|
return None if sel is _CANCEL else sel
|
|
@@ -583,7 +653,12 @@ def _edit_loop(stdscr, config: GatewayConfig, config_path: str):
|
|
|
583
653
|
status = "No changes to save"
|
|
584
654
|
elif key in (ord("q"), 27):
|
|
585
655
|
if dirty:
|
|
586
|
-
ans = _confirm(
|
|
656
|
+
ans = _confirm(
|
|
657
|
+
stdscr,
|
|
658
|
+
"Config changed — activate now?",
|
|
659
|
+
yes_label="Yes, activate now (hot-reload, no restart)",
|
|
660
|
+
no_label="No, discard changes",
|
|
661
|
+
)
|
|
587
662
|
if ans is True:
|
|
588
663
|
save_config(config, config_path)
|
|
589
664
|
reload_msg = _signal_reload(config_path)
|
|
@@ -822,6 +897,29 @@ def main() -> None:
|
|
|
822
897
|
help="Per-provider connectivity check timeout in seconds (default: 15.0)"
|
|
823
898
|
)
|
|
824
899
|
|
|
900
|
+
# flexgate status ...
|
|
901
|
+
status_p = sub.add_parser(
|
|
902
|
+
"status",
|
|
903
|
+
help="Show providers, fallback chains, usage/quota and active routes",
|
|
904
|
+
)
|
|
905
|
+
status_p.add_argument(
|
|
906
|
+
"--no-usage", action="store_true",
|
|
907
|
+
help="Skip the per-key usage/quota queries"
|
|
908
|
+
)
|
|
909
|
+
status_p.add_argument(
|
|
910
|
+
"--usage-timeout", type=float, default=15.0,
|
|
911
|
+
help="Per-key usage query timeout in seconds (default: 15.0)"
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
usage_p = sub.add_parser(
|
|
915
|
+
"usage",
|
|
916
|
+
help="Show per-key usage/quota for all configured providers",
|
|
917
|
+
)
|
|
918
|
+
usage_p.add_argument(
|
|
919
|
+
"--usage-timeout", type=float, default=15.0,
|
|
920
|
+
help="Per-key usage query timeout in seconds (default: 15.0)"
|
|
921
|
+
)
|
|
922
|
+
|
|
825
923
|
# flexgate settings ...
|
|
826
924
|
st = sub.add_parser("settings", help="Manage Claude Code settings")
|
|
827
925
|
st_sub = st.add_subparsers(dest="command")
|
|
@@ -888,6 +986,12 @@ def main() -> None:
|
|
|
888
986
|
elif args.group == "check":
|
|
889
987
|
cmd_check(args)
|
|
890
988
|
|
|
989
|
+
elif args.group == "status":
|
|
990
|
+
cmd_status(args)
|
|
991
|
+
|
|
992
|
+
elif args.group == "usage":
|
|
993
|
+
cmd_usage(args)
|
|
994
|
+
|
|
891
995
|
elif args.group == "settings":
|
|
892
996
|
handlers = {
|
|
893
997
|
"import": cmd_settings_import,
|
|
@@ -21,17 +21,36 @@ def ensure_home_dir() -> None:
|
|
|
21
21
|
os.makedirs(FLEXGATE_HOME, exist_ok=True)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
@dataclass
|
|
25
|
+
class ApiKey:
|
|
26
|
+
"""One API key for a provider, with an optional human-readable note."""
|
|
27
|
+
key: str
|
|
28
|
+
note: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
@dataclass
|
|
25
32
|
class ProviderConfig:
|
|
26
33
|
name: str
|
|
27
34
|
base_url: str
|
|
28
|
-
|
|
35
|
+
api_keys: list[ApiKey] = field(default_factory=list)
|
|
29
36
|
available_models: list[str] = field(default_factory=list)
|
|
30
37
|
|
|
31
38
|
@property
|
|
32
39
|
def default_model(self) -> str | None:
|
|
33
40
|
return self.available_models[0] if self.available_models else None
|
|
34
41
|
|
|
42
|
+
@property
|
|
43
|
+
def api_key(self) -> str:
|
|
44
|
+
"""The primary key (first of api_keys); later keys are fallbacks."""
|
|
45
|
+
return self.api_keys[0].key if self.api_keys else ""
|
|
46
|
+
|
|
47
|
+
@api_key.setter
|
|
48
|
+
def api_key(self, value: str) -> None:
|
|
49
|
+
if self.api_keys:
|
|
50
|
+
self.api_keys[0].key = value
|
|
51
|
+
else:
|
|
52
|
+
self.api_keys.append(ApiKey(key=value))
|
|
53
|
+
|
|
35
54
|
|
|
36
55
|
@dataclass
|
|
37
56
|
class RouteConfig:
|
|
@@ -82,6 +101,33 @@ def _parse_hhmm(value: str) -> int:
|
|
|
82
101
|
return h * 60 + m
|
|
83
102
|
|
|
84
103
|
|
|
104
|
+
def _parse_api_keys(name: str, prov: dict) -> list[ApiKey]:
|
|
105
|
+
"""Parse a provider's key list.
|
|
106
|
+
|
|
107
|
+
Current schema: ``api_keys`` — a list whose entries are either a plain
|
|
108
|
+
key string or a mapping ``{key: ..., note: ...}``. The legacy schema
|
|
109
|
+
(``api_key`` plus optional ``fallback_keys``) is still accepted.
|
|
110
|
+
"""
|
|
111
|
+
raw = prov.get("api_keys")
|
|
112
|
+
if raw is None:
|
|
113
|
+
raw = [prov["api_key"]]
|
|
114
|
+
raw.extend(prov.get("fallback_keys", []) or [])
|
|
115
|
+
if not isinstance(raw, list) or not raw:
|
|
116
|
+
raise ValueError(f"Provider '{name}': 'api_keys' must be a non-empty list")
|
|
117
|
+
keys: list[ApiKey] = []
|
|
118
|
+
for item in raw:
|
|
119
|
+
if isinstance(item, str):
|
|
120
|
+
keys.append(ApiKey(key=item))
|
|
121
|
+
elif isinstance(item, dict) and isinstance(item.get("key"), str):
|
|
122
|
+
keys.append(ApiKey(key=item["key"], note=str(item.get("note") or "")))
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"Provider '{name}': each api_keys entry must be a key string "
|
|
126
|
+
f"or a mapping with 'key' (and optional 'note')"
|
|
127
|
+
)
|
|
128
|
+
return keys
|
|
129
|
+
|
|
130
|
+
|
|
85
131
|
def _parse_routes(raw_routes: list[dict], providers: dict[str, ProviderConfig]) -> list[RouteConfig]:
|
|
86
132
|
routes: list[RouteConfig] = []
|
|
87
133
|
for r in raw_routes:
|
|
@@ -145,7 +191,7 @@ def load_config(path: str | None = None) -> GatewayConfig:
|
|
|
145
191
|
cfg.providers[name] = ProviderConfig(
|
|
146
192
|
name=name,
|
|
147
193
|
base_url=prov["base_url"].rstrip("/"),
|
|
148
|
-
|
|
194
|
+
api_keys=_parse_api_keys(name, prov),
|
|
149
195
|
available_models=[str(m) for m in available],
|
|
150
196
|
)
|
|
151
197
|
|
|
@@ -210,7 +256,10 @@ def save_config(cfg: GatewayConfig, path: str | None = None) -> None:
|
|
|
210
256
|
for name, prov in cfg.providers.items():
|
|
211
257
|
entry: dict = {
|
|
212
258
|
"base_url": prov.base_url,
|
|
213
|
-
"
|
|
259
|
+
"api_keys": [
|
|
260
|
+
{"key": k.key, "note": k.note} if k.note else k.key
|
|
261
|
+
for k in prov.api_keys
|
|
262
|
+
],
|
|
214
263
|
}
|
|
215
264
|
if prov.available_models:
|
|
216
265
|
entry["available_models"] = list(prov.available_models)
|
|
@@ -244,38 +293,42 @@ server:
|
|
|
244
293
|
|
|
245
294
|
# Each provider must declare `available_models`. The first entry is used as the
|
|
246
295
|
# fallback model whenever a route below omits its own `model:` field.
|
|
247
|
-
#
|
|
248
|
-
#
|
|
296
|
+
#
|
|
297
|
+
# `api_keys`: one or more API keys for the SAME upstream; each entry is either
|
|
298
|
+
# a plain key string or {key, note} where `note` is a free-form label stored
|
|
299
|
+
# in the config (shown by `flexgate status`, included in fallback logs).
|
|
300
|
+
# When a key fails with a retryable error (401/402/403/429/5xx/529 or a
|
|
301
|
+
# connection error — e.g. quota exhausted or provider overloaded), the request
|
|
302
|
+
# is retried with the next key in order. Multiple accounts on the same
|
|
303
|
+
# upstream should be configured this way instead of as separate providers.
|
|
249
304
|
providers:
|
|
250
305
|
minimax:
|
|
251
306
|
base_url: "https://api.minimaxi.com/anthropic"
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
available_models:
|
|
259
|
-
- "MiniMax-M3"
|
|
260
|
-
minimax-ywj:
|
|
261
|
-
base_url: "https://api.minimaxi.com/anthropic"
|
|
262
|
-
api_key: "your-minimax-ywj-api-key"
|
|
307
|
+
api_keys:
|
|
308
|
+
- key: "your-minimax-api-key"
|
|
309
|
+
note: "main account"
|
|
310
|
+
- key: "your-minimax-api-key-2"
|
|
311
|
+
note: "backup account"
|
|
312
|
+
- "your-minimax-api-key-3" # plain string entry (no note)
|
|
263
313
|
available_models:
|
|
264
314
|
- "MiniMax-M3"
|
|
265
315
|
zai:
|
|
266
316
|
base_url: "https://api.z.ai/api/anthropic"
|
|
267
|
-
|
|
317
|
+
api_keys:
|
|
318
|
+
- "your-zai-api-key"
|
|
268
319
|
available_models:
|
|
269
320
|
- "glm-5.3" # used as fallback when a route omits `model`
|
|
270
321
|
- "glm-4.6v"
|
|
271
322
|
xiaomi:
|
|
272
323
|
base_url: "https://token-plan-cn.xiaomimimo.com/anthropic"
|
|
273
|
-
|
|
324
|
+
api_keys:
|
|
325
|
+
- "your-xiaomi-api-key"
|
|
274
326
|
available_models:
|
|
275
327
|
- "mimo-v2.5-pro"
|
|
276
328
|
ustc:
|
|
277
329
|
base_url: "https://api.llm.ustc.edu.cn"
|
|
278
|
-
|
|
330
|
+
api_keys:
|
|
331
|
+
- "your-ustc-api-key"
|
|
279
332
|
available_models:
|
|
280
333
|
- "deepseek-v4-pro"
|
|
281
334
|
|
|
@@ -104,7 +104,10 @@ def _check_providers(config, findings: list[Finding]) -> None:
|
|
|
104
104
|
findings.append(Finding(FAIL, "Providers", "none configured"))
|
|
105
105
|
return
|
|
106
106
|
|
|
107
|
-
placeholders = [
|
|
107
|
+
placeholders = [
|
|
108
|
+
name for name, p in config.providers.items()
|
|
109
|
+
if all(_is_placeholder_key(k.key) for k in p.api_keys)
|
|
110
|
+
]
|
|
108
111
|
if placeholders:
|
|
109
112
|
findings.append(Finding(WARN, "Provider keys",
|
|
110
113
|
f"placeholder api_key in: {', '.join(placeholders)}"))
|