flexgate 0.6.0__tar.gz → 0.8.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.6.0 → flexgate-0.8.0}/CLAUDE.md +9 -3
- {flexgate-0.6.0 → flexgate-0.8.0}/PKG-INFO +38 -6
- {flexgate-0.6.0 → flexgate-0.8.0}/README.md +37 -5
- flexgate-0.8.0/docs/api.md +66 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/cli.md +6 -1
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/conf.py +3 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/configuration.md +1 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/development.md +5 -2
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/index.md +8 -2
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/usage.md +30 -3
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/__init__.py +1 -1
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/cli.py +23 -6
- flexgate-0.8.0/flexgate/fallback.py +99 -0
- flexgate-0.8.0/flexgate/images.py +192 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/server.py +13 -1
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/usage.py +128 -8
- {flexgate-0.6.0 → flexgate-0.8.0}/.github/workflows/docs.yml +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/.github/workflows/release.yml +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/.gitignore +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/config.yaml.template +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/Makefile +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/_static/.gitkeep +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/quickstart.md +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/docs/requirements.txt +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/__main__.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/config.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/doctor.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/healthcheck.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/main.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/migrate.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/proxy.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/router.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/service.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/settings.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/sync.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/ui.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/update.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/pyproject.toml +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/test.png +0 -0
- {flexgate-0.6.0 → flexgate-0.8.0}/test_multimodal.py +0 -0
- {flexgate-0.6.0 → flexgate-0.8.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 status # providers, fallback chains, per-key usage, active routes
|
|
25
|
+
flexgate usage [--force] # per-key usage/quota only; --force rechecks cached failures
|
|
25
26
|
flexgate log # service journal (-f follow, -r route lines only)
|
|
26
27
|
|
|
27
28
|
# Config
|
|
@@ -73,10 +74,13 @@ without a systemd user instance.
|
|
|
73
74
|
|
|
74
75
|
```
|
|
75
76
|
Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
|
|
76
|
-
→ server.py (Starlette app,
|
|
77
|
+
→ server.py (Starlette app, /v1/messages + /v1/images/* routes)
|
|
77
78
|
→ router.py (resolve(): regex match model, check schedule windows first, then default routes)
|
|
78
79
|
→ proxy.py (rewrite model field if override, swap x-api-key, SSE streaming pass-through)
|
|
79
80
|
→ Upstream provider (z.ai, minimax, xiaomi, etc.)
|
|
81
|
+
|
|
82
|
+
OpenAI-style client → POST /v1/images/generations
|
|
83
|
+
→ images.py (translate to MiniMax /v1/image_generation; provider auto-detected by base_url)
|
|
80
84
|
```
|
|
81
85
|
|
|
82
86
|
### Source files (`flexgate/`)
|
|
@@ -89,7 +93,9 @@ Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
|
|
|
89
93
|
| `router.py` | `resolve(config, model)` — schedule-first then default routes, first regex match wins; model aliases normalized before matching |
|
|
90
94
|
| `proxy.py` | `handle_request()` — httpx async proxy, per-key fallback retry loop, SSE streaming + JSON pass-through |
|
|
91
95
|
| `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 |
|
|
92
|
-
| `server.py` | Starlette app creation, `POST /v1/messages`
|
|
96
|
+
| `server.py` | Starlette app creation, `POST /v1/messages` + `POST /v1/images/{generations,edits}` endpoints, `SIGUSR1` lifespan reload |
|
|
97
|
+
| `images.py` | OpenAI Images API → MiniMax `/v1/image_generation` translation (`image-01`, size→width/height clamped to 512–2048 mult of 8, `data.image_base64[]`→`data[].b64_json`); edits endpoint returns 501; unknown image model names get 501 `model_not_supported` |
|
|
98
|
+
| `fallback.py` | Catch-all route: merged Anthropic/OpenAI-shaped JSON errors for every unserved path — 404 `route_not_found` (lists valid routes), 405 `method_not_allowed`, 501 `endpoint_not_supported` per recognized API family with an actionable reason |
|
|
93
99
|
| `main.py` | Thin bootstrap: load config → create app → run uvicorn |
|
|
94
100
|
| `service.py` | Authoritative systemd user-service install/start/stop/restart/reload/status and legacy PID migration |
|
|
95
101
|
| `healthcheck.py` | Upstream connectivity probing (`POST /v1/messages`, max_tokens=1) for each referenced (provider, model) pair; called by `doctor` (`flexgate check` is a deprecated alias of doctor) |
|
|
@@ -106,7 +112,7 @@ Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
|
|
|
106
112
|
- **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.
|
|
107
113
|
- **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.
|
|
108
114
|
- **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. Each *route* has an active-key pointer (`active_key` in YAML, 1-based; `RouteConfig.key_index` 0-based, default first key): requests on that route start from the pointed key, and on a retryable failure (HTTP 401/402/403/429/500/502/503/529, connect error, or timeout) the next key is tried and the pointer advances with it (in memory only, wrapping around — it is not written back to the file); every key is tried at most once and a full circle of failures returns the last error. The pointer is set interactively via `flexgate config edit` → "api keys" (pick a route, with live per-key usage) or by editing the route's `active_key` directly. `resolve()` (`router.py`) returns the matched `RouteConfig` so the proxy can read/advance its pointer. 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 the settings import code.
|
|
109
|
-
- **Usage inspection** (`usage.py`): `flexgate status` maps `base_url` to a platform adapter (`_ADAPTERS`): MiniMax → `{origin}/v1/api/openplatform/coding_plan/remains` (only the "general" text-model quota entry is used; its counts are usually 0/0, so usage is read from the `*_remaining_percent` fields), 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.
|
|
115
|
+
- **Usage inspection** (`usage.py`): `flexgate status` maps `base_url` to a platform adapter (`_ADAPTERS`): MiniMax → `{origin}/v1/api/openplatform/coding_plan/remains` (only the "general" text-model quota entry is used; its counts are usually 0/0, so usage is read from the `*_remaining_percent` fields), 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. Any key whose usage query errored — a hard failure (adapter AND probe), or an adapter error merely recovered by the probe — is recorded in `~/.flexgate/usage-cache.json` (sha256 fingerprint, never key material; entry keeps the `ok` flag and output lines) and skipped by later `status`/`usage` runs, shown as `cached (… ago)` / `cached failure (… ago)` with a hint; `flexgate usage --force` rechecks them and a clean success clears the entry. Placeholder keys are never cached.
|
|
110
116
|
- **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.
|
|
111
117
|
- **Schedule-based overrides**: Optional time windows (e.g. 22:00-06:00) override default routes. Overnight wrap is supported.
|
|
112
118
|
- **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.8.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
|
|
@@ -89,6 +89,7 @@ systemd **用户服务**是 Linux 上唯一推荐的持久化运行方式,负
|
|
|
89
89
|
```bash
|
|
90
90
|
flexgate service install # 安装、启用并立即启动
|
|
91
91
|
flexgate service install --no-start # 仅安装并启用,不立即启动
|
|
92
|
+
flexgate service install --no-claude-settings # 跳过修改 ~/.claude/settings.json 的交互询问
|
|
92
93
|
flexgate service start # 启动;自动修复旧格式或失效的 unit
|
|
93
94
|
flexgate service stop # 停止
|
|
94
95
|
flexgate service restart # 重启
|
|
@@ -113,7 +114,7 @@ flexgate service uninstall # 停止、禁用并删除 unit
|
|
|
113
114
|
```bash
|
|
114
115
|
flexgate --version # 打印版本号(service status / 裸 flexgate 也会显示)
|
|
115
116
|
flexgate doctor # 只读体检:Python、PyPI 新版、配置 schema、端口、systemd、Claude settings、上游连通性
|
|
116
|
-
flexgate doctor --offline #
|
|
117
|
+
flexgate doctor --offline # 跳过全部网络检查(PyPI 新版检查 + 上游探测)
|
|
117
118
|
flexgate update # 一键升级:pip/pipx/uv 升级包 + 迁移配置 schema + 热重载服务
|
|
118
119
|
flexgate update --check # 只报告将要做什么,不改动
|
|
119
120
|
flexgate update --config-only # 只迁移配置,不升级包
|
|
@@ -170,6 +171,7 @@ flexgate status --no-usage # 跳过用量查询,只看配置
|
|
|
170
171
|
flexgate status --usage-timeout 30
|
|
171
172
|
flexgate usage # 只看每个 key 的用量/额度(不打印配置和路由)
|
|
172
173
|
flexgate usage --usage-timeout 30
|
|
174
|
+
flexgate usage --force # 重查上次失败的 key(默认跳过,读缓存)
|
|
173
175
|
```
|
|
174
176
|
|
|
175
177
|
`flexgate status` 展示当前配置中的所有 provider、每个 provider 的 key 及
|
|
@@ -190,6 +192,24 @@ fallback 链、当前生效的路由,并逐一查询**每个 key** 的用量/
|
|
|
190
192
|
如果某平台的专用接口调用失败,flexgate 会自动退化为 minimal probe 再试一次。
|
|
191
193
|
用量查询接口多为平台内部接口,可能随时变动;查询结果仅供参考。
|
|
192
194
|
|
|
195
|
+
**查询失败缓存**:用量查询中报错的 key(彻底失败,或专用接口报错但 probe
|
|
196
|
+
证明 key 仍可用,例如智谱的按量付费 key)会记入
|
|
197
|
+
`~/.flexgate/usage-cache.json`(只存 key 指纹,不存明文),此后 `status` /
|
|
198
|
+
`usage` 默认跳过这些 key 并显示缓存错误与提示(`cached failure`);
|
|
199
|
+
`flexgate usage --force` 强制重查并更新缓存,干净成功后自动恢复实时查询。
|
|
200
|
+
|
|
201
|
+
### HTTP API(图像生成与结构化错误)
|
|
202
|
+
|
|
203
|
+
除 `POST /v1/messages` 外,网关还提供 OpenAI 兼容的图像生成端点
|
|
204
|
+
`POST /v1/images/generations`:自动发现配置中的 MiniMax provider,把请求
|
|
205
|
+
翻译为 MiniMax `image-01` 文生图(模型名提示如 `gpt-image`/`dall-e` 均可,
|
|
206
|
+
`size` 钳制到 512–2048 的 8 倍数,响应为 `data[].b64_json`)。
|
|
207
|
+
`/v1/images/edits` 固定返回 501(上游仅支持文生图)。
|
|
208
|
+
|
|
209
|
+
所有未支持的路径(如 `/v1/chat/completions`、`/v1/embeddings`、`count_tokens`)
|
|
210
|
+
返回 Anthropic/OpenAI 双兼容的结构化 JSON 错误(404/405/501),而不是裸
|
|
211
|
+
404。详见[HTTP API 文档](https://agony5757.github.io/flexible-gateway/api.html)。
|
|
212
|
+
|
|
193
213
|
### 前台调试
|
|
194
214
|
|
|
195
215
|
`run` 是独立的顶层调试命令,不属于持久化服务模式:
|
|
@@ -244,16 +264,19 @@ flexgate config set haiku MiniMax-M3
|
|
|
244
264
|
|
|
245
265
|
```text
|
|
246
266
|
Flexgate config — ~/.flexgate/config.yaml
|
|
247
|
-
↑/↓ move · Enter edit
|
|
267
|
+
↑/↓ move · Enter edit · s save · q quit
|
|
248
268
|
|
|
249
269
|
▶ opus ustc / deepseek-v4-pro
|
|
250
270
|
sonnet ustc / deepseek-v4-pro
|
|
251
271
|
haiku ustc / deepseek-v4-pro
|
|
272
|
+
fallback ustc / deepseek-v4-pro
|
|
273
|
+
api keys select the active key per route
|
|
252
274
|
|
|
253
275
|
○ no unsaved changes
|
|
254
276
|
```
|
|
255
277
|
|
|
256
|
-
- 方向键选中某个 tier(opus/sonnet/haiku
|
|
278
|
+
- 方向键选中某个 tier(opus/sonnet/haiku)或 `fallback`(兜底路由 `.*`,未命中任何 tier 的请求走它),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
|
|
279
|
+
- 选中 `api keys` 回车进入:先选一条**路由**,再选它的 **active key**;key 列表会实时查询每个 key 的用量/有效性(与 `flexgate status` 相同),并标注当前 active 的 key。
|
|
257
280
|
- model 列表包含:`available_models` 中的各个模型、「使用 provider 默认(首个可用模型,不写死 model)」、以及「自定义模型…」(手动输入)。
|
|
258
281
|
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1 热重载,**无需重启即生效**),按 `q` 退出(有未保存改动时会询问 "Config changed — activate now?":选 Yes 立即保存并热重载生效,选 No 放弃改动);子菜单中按 `Esc`/`←` 返回上一级。
|
|
259
282
|
- 需要交互式终端(TTY);非交互场景请改用 `flexgate config set`。
|
|
@@ -286,6 +309,7 @@ API_TIMEOUT_MS、三个 `ANTHROPIC_DEFAULT_*_MODEL`),settings.json 中的
|
|
|
286
309
|
| `~/.flexgate/config.yaml` | 主配置文件 |
|
|
287
310
|
| `~/.flexgate/service-state.json` | 最近一次成功启动所应用的 config 路径与 endpoint |
|
|
288
311
|
| `~/.flexgate/update-check.json` | PyPI 新版本检查的缓存(24h 有效期) |
|
|
312
|
+
| `~/.flexgate/usage-cache.json` | 用量查询失败缓存(key 指纹 + 错误文本;`usage --force` 重查后更新) |
|
|
289
313
|
| `~/.config/systemd/user/flexgate.service` | 唯一的持久化服务 unit |
|
|
290
314
|
| systemd journal | 服务日志(`flexgate log`,即 `journalctl --user -u flexgate`) |
|
|
291
315
|
|
|
@@ -337,6 +361,7 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
337
361
|
- pattern: "^claude-sonnet"
|
|
338
362
|
provider: minimax
|
|
339
363
|
model: "MiniMax-M3"
|
|
364
|
+
active_key: 2 # 可选:该路由从 minimax 的第 2 个 key 开始用(1 起始,默认 1)
|
|
340
365
|
- pattern: "^claude-haiku"
|
|
341
366
|
provider: minimax
|
|
342
367
|
model: "MiniMax-M3"
|
|
@@ -354,9 +379,10 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
354
379
|
| `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
|
|
355
380
|
| `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
|
|
356
381
|
| `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
|
|
357
|
-
| `routes[].pattern` | 正则匹配请求中的 model
|
|
382
|
+
| `routes[].pattern` | 正则匹配请求中的 model 字段(匹配前归一化裸别名 `sonnet`/`opus`/`haiku`/`default` 与旧编号名) |
|
|
358
383
|
| `routes[].provider` | 路由到的 provider 名称 |
|
|
359
384
|
| `routes[].model` | 可选,替换发给 provider 的模型名 |
|
|
385
|
+
| `routes[].active_key` | 可选,1 起始的 provider api_keys 序号,该路由的请求从哪个 key 开始(默认 1,即第一个 key) |
|
|
360
386
|
| `schedule[].name` | 定时规则名称 |
|
|
361
387
|
| `schedule[].start/end` | 时间窗口(HH:MM 格式,支持跨夜如 22:00-06:00) |
|
|
362
388
|
| `schedule[].routes` | 该时间窗口内生效的路由(格式同 `routes`) |
|
|
@@ -368,7 +394,13 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
368
394
|
`{key, note}` 加一个存在配置里的备注(`flexgate status` 和 fallback 日志
|
|
369
395
|
都会显示 note,方便分辨是哪个账号的 key):
|
|
370
396
|
|
|
371
|
-
-
|
|
397
|
+
- 每条路由有一个「当前 key」指针(路由上的 `active_key`,默认指向
|
|
398
|
+
provider 的第一个 key):该路由的请求从指针指向的 key 开始;key 失败
|
|
399
|
+
时自动换用下一个 key(循环一圈),指针也随之自动前进——后续请求直接
|
|
400
|
+
从能用的 key 开始。**所有 key 轮换一圈都失败时,请求返回最后一次的
|
|
401
|
+
错误**。指针的运行时前进只存在于网关进程内存中,不写回配置文件;
|
|
402
|
+
可通过 `flexgate config edit` → "api keys" 交互切换。
|
|
403
|
+
- 请求先走 active key;失败时按列表顺序自动重试后续 key,直到某个 key
|
|
372
404
|
成功或返回不可重试的错误。
|
|
373
405
|
- 触发切换的条件:HTTP **401 / 402 / 403 / 429 / 500 / 502 / 503 / 529**
|
|
374
406
|
(key 失效、余额/额度耗尽、限流、平台过载)以及连接错误、超时。
|
|
@@ -72,6 +72,7 @@ systemd **用户服务**是 Linux 上唯一推荐的持久化运行方式,负
|
|
|
72
72
|
```bash
|
|
73
73
|
flexgate service install # 安装、启用并立即启动
|
|
74
74
|
flexgate service install --no-start # 仅安装并启用,不立即启动
|
|
75
|
+
flexgate service install --no-claude-settings # 跳过修改 ~/.claude/settings.json 的交互询问
|
|
75
76
|
flexgate service start # 启动;自动修复旧格式或失效的 unit
|
|
76
77
|
flexgate service stop # 停止
|
|
77
78
|
flexgate service restart # 重启
|
|
@@ -96,7 +97,7 @@ flexgate service uninstall # 停止、禁用并删除 unit
|
|
|
96
97
|
```bash
|
|
97
98
|
flexgate --version # 打印版本号(service status / 裸 flexgate 也会显示)
|
|
98
99
|
flexgate doctor # 只读体检:Python、PyPI 新版、配置 schema、端口、systemd、Claude settings、上游连通性
|
|
99
|
-
flexgate doctor --offline #
|
|
100
|
+
flexgate doctor --offline # 跳过全部网络检查(PyPI 新版检查 + 上游探测)
|
|
100
101
|
flexgate update # 一键升级:pip/pipx/uv 升级包 + 迁移配置 schema + 热重载服务
|
|
101
102
|
flexgate update --check # 只报告将要做什么,不改动
|
|
102
103
|
flexgate update --config-only # 只迁移配置,不升级包
|
|
@@ -153,6 +154,7 @@ flexgate status --no-usage # 跳过用量查询,只看配置
|
|
|
153
154
|
flexgate status --usage-timeout 30
|
|
154
155
|
flexgate usage # 只看每个 key 的用量/额度(不打印配置和路由)
|
|
155
156
|
flexgate usage --usage-timeout 30
|
|
157
|
+
flexgate usage --force # 重查上次失败的 key(默认跳过,读缓存)
|
|
156
158
|
```
|
|
157
159
|
|
|
158
160
|
`flexgate status` 展示当前配置中的所有 provider、每个 provider 的 key 及
|
|
@@ -173,6 +175,24 @@ fallback 链、当前生效的路由,并逐一查询**每个 key** 的用量/
|
|
|
173
175
|
如果某平台的专用接口调用失败,flexgate 会自动退化为 minimal probe 再试一次。
|
|
174
176
|
用量查询接口多为平台内部接口,可能随时变动;查询结果仅供参考。
|
|
175
177
|
|
|
178
|
+
**查询失败缓存**:用量查询中报错的 key(彻底失败,或专用接口报错但 probe
|
|
179
|
+
证明 key 仍可用,例如智谱的按量付费 key)会记入
|
|
180
|
+
`~/.flexgate/usage-cache.json`(只存 key 指纹,不存明文),此后 `status` /
|
|
181
|
+
`usage` 默认跳过这些 key 并显示缓存错误与提示(`cached failure`);
|
|
182
|
+
`flexgate usage --force` 强制重查并更新缓存,干净成功后自动恢复实时查询。
|
|
183
|
+
|
|
184
|
+
### HTTP API(图像生成与结构化错误)
|
|
185
|
+
|
|
186
|
+
除 `POST /v1/messages` 外,网关还提供 OpenAI 兼容的图像生成端点
|
|
187
|
+
`POST /v1/images/generations`:自动发现配置中的 MiniMax provider,把请求
|
|
188
|
+
翻译为 MiniMax `image-01` 文生图(模型名提示如 `gpt-image`/`dall-e` 均可,
|
|
189
|
+
`size` 钳制到 512–2048 的 8 倍数,响应为 `data[].b64_json`)。
|
|
190
|
+
`/v1/images/edits` 固定返回 501(上游仅支持文生图)。
|
|
191
|
+
|
|
192
|
+
所有未支持的路径(如 `/v1/chat/completions`、`/v1/embeddings`、`count_tokens`)
|
|
193
|
+
返回 Anthropic/OpenAI 双兼容的结构化 JSON 错误(404/405/501),而不是裸
|
|
194
|
+
404。详见[HTTP API 文档](https://agony5757.github.io/flexible-gateway/api.html)。
|
|
195
|
+
|
|
176
196
|
### 前台调试
|
|
177
197
|
|
|
178
198
|
`run` 是独立的顶层调试命令,不属于持久化服务模式:
|
|
@@ -227,16 +247,19 @@ flexgate config set haiku MiniMax-M3
|
|
|
227
247
|
|
|
228
248
|
```text
|
|
229
249
|
Flexgate config — ~/.flexgate/config.yaml
|
|
230
|
-
↑/↓ move · Enter edit
|
|
250
|
+
↑/↓ move · Enter edit · s save · q quit
|
|
231
251
|
|
|
232
252
|
▶ opus ustc / deepseek-v4-pro
|
|
233
253
|
sonnet ustc / deepseek-v4-pro
|
|
234
254
|
haiku ustc / deepseek-v4-pro
|
|
255
|
+
fallback ustc / deepseek-v4-pro
|
|
256
|
+
api keys select the active key per route
|
|
235
257
|
|
|
236
258
|
○ no unsaved changes
|
|
237
259
|
```
|
|
238
260
|
|
|
239
|
-
- 方向键选中某个 tier(opus/sonnet/haiku
|
|
261
|
+
- 方向键选中某个 tier(opus/sonnet/haiku)或 `fallback`(兜底路由 `.*`,未命中任何 tier 的请求走它),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
|
|
262
|
+
- 选中 `api keys` 回车进入:先选一条**路由**,再选它的 **active key**;key 列表会实时查询每个 key 的用量/有效性(与 `flexgate status` 相同),并标注当前 active 的 key。
|
|
240
263
|
- model 列表包含:`available_models` 中的各个模型、「使用 provider 默认(首个可用模型,不写死 model)」、以及「自定义模型…」(手动输入)。
|
|
241
264
|
- 按 `s` 保存(并向运行中的网关发送 SIGUSR1 热重载,**无需重启即生效**),按 `q` 退出(有未保存改动时会询问 "Config changed — activate now?":选 Yes 立即保存并热重载生效,选 No 放弃改动);子菜单中按 `Esc`/`←` 返回上一级。
|
|
242
265
|
- 需要交互式终端(TTY);非交互场景请改用 `flexgate config set`。
|
|
@@ -269,6 +292,7 @@ API_TIMEOUT_MS、三个 `ANTHROPIC_DEFAULT_*_MODEL`),settings.json 中的
|
|
|
269
292
|
| `~/.flexgate/config.yaml` | 主配置文件 |
|
|
270
293
|
| `~/.flexgate/service-state.json` | 最近一次成功启动所应用的 config 路径与 endpoint |
|
|
271
294
|
| `~/.flexgate/update-check.json` | PyPI 新版本检查的缓存(24h 有效期) |
|
|
295
|
+
| `~/.flexgate/usage-cache.json` | 用量查询失败缓存(key 指纹 + 错误文本;`usage --force` 重查后更新) |
|
|
272
296
|
| `~/.config/systemd/user/flexgate.service` | 唯一的持久化服务 unit |
|
|
273
297
|
| systemd journal | 服务日志(`flexgate log`,即 `journalctl --user -u flexgate`) |
|
|
274
298
|
|
|
@@ -320,6 +344,7 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
320
344
|
- pattern: "^claude-sonnet"
|
|
321
345
|
provider: minimax
|
|
322
346
|
model: "MiniMax-M3"
|
|
347
|
+
active_key: 2 # 可选:该路由从 minimax 的第 2 个 key 开始用(1 起始,默认 1)
|
|
323
348
|
- pattern: "^claude-haiku"
|
|
324
349
|
provider: minimax
|
|
325
350
|
model: "MiniMax-M3"
|
|
@@ -337,9 +362,10 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
337
362
|
| `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
|
|
338
363
|
| `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
|
|
339
364
|
| `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
|
|
340
|
-
| `routes[].pattern` | 正则匹配请求中的 model
|
|
365
|
+
| `routes[].pattern` | 正则匹配请求中的 model 字段(匹配前归一化裸别名 `sonnet`/`opus`/`haiku`/`default` 与旧编号名) |
|
|
341
366
|
| `routes[].provider` | 路由到的 provider 名称 |
|
|
342
367
|
| `routes[].model` | 可选,替换发给 provider 的模型名 |
|
|
368
|
+
| `routes[].active_key` | 可选,1 起始的 provider api_keys 序号,该路由的请求从哪个 key 开始(默认 1,即第一个 key) |
|
|
343
369
|
| `schedule[].name` | 定时规则名称 |
|
|
344
370
|
| `schedule[].start/end` | 时间窗口(HH:MM 格式,支持跨夜如 22:00-06:00) |
|
|
345
371
|
| `schedule[].routes` | 该时间窗口内生效的路由(格式同 `routes`) |
|
|
@@ -351,7 +377,13 @@ routes: # 从上到下匹配,首个命中生效
|
|
|
351
377
|
`{key, note}` 加一个存在配置里的备注(`flexgate status` 和 fallback 日志
|
|
352
378
|
都会显示 note,方便分辨是哪个账号的 key):
|
|
353
379
|
|
|
354
|
-
-
|
|
380
|
+
- 每条路由有一个「当前 key」指针(路由上的 `active_key`,默认指向
|
|
381
|
+
provider 的第一个 key):该路由的请求从指针指向的 key 开始;key 失败
|
|
382
|
+
时自动换用下一个 key(循环一圈),指针也随之自动前进——后续请求直接
|
|
383
|
+
从能用的 key 开始。**所有 key 轮换一圈都失败时,请求返回最后一次的
|
|
384
|
+
错误**。指针的运行时前进只存在于网关进程内存中,不写回配置文件;
|
|
385
|
+
可通过 `flexgate config edit` → "api keys" 交互切换。
|
|
386
|
+
- 请求先走 active key;失败时按列表顺序自动重试后续 key,直到某个 key
|
|
355
387
|
成功或返回不可重试的错误。
|
|
356
388
|
- 触发切换的条件:HTTP **401 / 402 / 403 / 429 / 500 / 502 / 503 / 529**
|
|
357
389
|
(key 失效、余额/额度耗尽、限流、平台过载)以及连接错误、超时。
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# HTTP API
|
|
2
|
+
|
|
3
|
+
Flexgate 在本地监听(默认 `127.0.0.1:8765`)提供以下端点:
|
|
4
|
+
|
|
5
|
+
| 端点 | 说明 |
|
|
6
|
+
|------|------|
|
|
7
|
+
| `POST /v1/messages` | Anthropic Messages API 代理(核心端点) |
|
|
8
|
+
| `POST /v1/images/generations` | OpenAI 兼容图像生成(转 MiniMax `image-01`) |
|
|
9
|
+
| `POST /v1/images/edits` | 固定返回 501(上游仅支持文生图) |
|
|
10
|
+
| 其他路径 | 结构化 JSON 错误(404 / 405 / 501,见下文) |
|
|
11
|
+
|
|
12
|
+
## POST /v1/messages
|
|
13
|
+
|
|
14
|
+
标准的 Anthropic Messages 格式代理。请求体中的 `model` 字段用于路由:
|
|
15
|
+
|
|
16
|
+
- **模型别名归一化**:匹配路由前,裸别名(`sonnet` / `opus` / `haiku` /
|
|
17
|
+
`default`,大小写不敏感,可带 `[plan]` 后缀)映射为对应的
|
|
18
|
+
`claude-<tier>` 前缀(`default` → sonnet);旧编号名
|
|
19
|
+
(如 `claude-3-7-sonnet-latest`)归约为 tier 前缀;其余名称原样匹配。
|
|
20
|
+
- 命中路由后,仅当路由显式配置了 `model` 时才改写上游请求的 model 字段;
|
|
21
|
+
认证头替换为该 provider 当前 active key 的 `x-api-key`。
|
|
22
|
+
- 流式(SSE)响应按原始字节透传,不做解析;非流式 JSON 原样转发。
|
|
23
|
+
- key 级 fallback:可重试错误按 `api_keys` 顺序切换(详见
|
|
24
|
+
[配置文件](configuration.md#key-fallback))。
|
|
25
|
+
- **多模态降级**:请求携带图片块且目标模型不在多模态允许列表
|
|
26
|
+
(`MULTIMODAL_MODELS`,当前为 `MiniMax-M3`、`glm-4.6v`)中时,图片会被
|
|
27
|
+
剥离,并在请求与响应中注入 `[flexgate]` 文本说明,避免非多模态上游报 4xx。
|
|
28
|
+
|
|
29
|
+
## POST /v1/images/generations
|
|
30
|
+
|
|
31
|
+
OpenAI Images API 兼容端点,让 OpenAI 风格的客户端无需任何额外配置即可
|
|
32
|
+
通过网关生成图片:
|
|
33
|
+
|
|
34
|
+
- **上游自动发现**:使用配置中 `base_url` 指向 MiniMax
|
|
35
|
+
(`api.minimaxi.com` / `api.minimax.io`)的 provider 及其 key,无需单独
|
|
36
|
+
配置图像 provider。
|
|
37
|
+
- **模型名映射**:请求中的 `model` 含已知图像模型提示(`gpt-image`、
|
|
38
|
+
`dall-e`、`image-01`、`seedream`、`flux`、`sdxl`、`stable-diffusion`、
|
|
39
|
+
`minimax` 等)即接受并映射为 MiniMax 的 `image-01`;无法识别的模型名
|
|
40
|
+
返回 501 `model_not_supported`。
|
|
41
|
+
- **尺寸**:`size: "WxH"` 映射为 width/height,各边钳制到 512–2048 并取
|
|
42
|
+
8 的倍数;无法解析的 size 忽略(用上游默认值)。
|
|
43
|
+
- **prompt** 超过 1500 字符会被截断。
|
|
44
|
+
- **响应**:MiniMax 返回的 `data.image_base64[]`(JPEG)原样转发为 OpenAI
|
|
45
|
+
风格的 `data[].b64_json`,不引入转码依赖。
|
|
46
|
+
|
|
47
|
+
## POST /v1/images/edits
|
|
48
|
+
|
|
49
|
+
固定返回 501:MiniMax `image-01` 仅支持文生图,没有通用的图像编辑 /
|
|
50
|
+
inpainting 能力。
|
|
51
|
+
|
|
52
|
+
## 未支持端点的结构化错误
|
|
53
|
+
|
|
54
|
+
所有未匹配的请求都不会得到裸 404,而是返回 Anthropic / OpenAI 双兼容
|
|
55
|
+
的 JSON 错误体,客户端可以据此判断"端口错了"还是"网关不支持该能力":
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{"type": "error", "error": {"type": "...", "message": "...", "code": "..."}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| 状态码 | code | 触发条件 |
|
|
62
|
+
|--------|------|----------|
|
|
63
|
+
| 404 | `route_not_found` | 完全未知的路径;message 中列出全部有效路由 |
|
|
64
|
+
| 405 | `method_not_allowed` | 路径有效但 HTTP 方法不对 |
|
|
65
|
+
| 501 | `endpoint_not_supported` | 识别出的 API 家族但网关不服务(`/v1/chat/completions`、`/v1/responses`、`/v1/messages/count_tokens`、`/v1/embeddings` 等),message 给出可操作的建议 |
|
|
66
|
+
| 501 | `capability_not_supported` / `model_not_supported` | 有效路由上不支持的操作或模型名(由所属模块返回) |
|
|
@@ -19,6 +19,7 @@ systemd **用户服务**是 Linux 上唯一推荐的持久化运行方式,负
|
|
|
19
19
|
```bash
|
|
20
20
|
flexgate service install # 安装、启用并立即启动
|
|
21
21
|
flexgate service install --no-start # 仅安装并启用,不立即启动
|
|
22
|
+
flexgate service install --no-claude-settings # 跳过修改 ~/.claude/settings.json 的交互询问
|
|
22
23
|
flexgate service start # 启动;自动修复旧格式或失效的 unit
|
|
23
24
|
flexgate service stop # 停止
|
|
24
25
|
flexgate service restart # 重启
|
|
@@ -55,9 +56,13 @@ flexgate status --no-usage # 跳过用量查询,只看配置
|
|
|
55
56
|
flexgate status --usage-timeout 30
|
|
56
57
|
flexgate usage # 只看每个 key 的用量/额度(不打印配置和路由)
|
|
57
58
|
flexgate usage --usage-timeout 30
|
|
59
|
+
flexgate usage --force # 重查上次失败的 key(默认跳过,读缓存)
|
|
58
60
|
```
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
查询失败的 key 会被缓存并在此后的 `status` / `usage` 中默认跳过
|
|
63
|
+
(显示为 `cached failure` 并附带提示),`--force` 强制重新查询;
|
|
64
|
+
详见[用量查询](usage.md)。各平台的用量查询适配器同样见
|
|
65
|
+
[用量查询](usage.md)。
|
|
61
66
|
|
|
62
67
|
## 查看日志(log)
|
|
63
68
|
|
|
@@ -29,6 +29,9 @@ myst_enable_extensions = [
|
|
|
29
29
|
"linkify",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
+
# Allow cross-document links to implicit heading anchors (e.g. configuration.md#key-fallback)
|
|
33
|
+
myst_heading_anchors = 3
|
|
34
|
+
|
|
32
35
|
source_suffix = {".md": "markdown"}
|
|
33
36
|
templates_path = ["_templates"]
|
|
34
37
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
| `~/.flexgate/config.yaml` | 主配置文件 |
|
|
8
8
|
| `~/.flexgate/service-state.json` | 最近一次成功启动所应用的 config 路径与 endpoint |
|
|
9
9
|
| `~/.flexgate/update-check.json` | PyPI 新版本检查的缓存(24h 有效期) |
|
|
10
|
+
| `~/.flexgate/usage-cache.json` | 用量查询失败缓存(key 指纹 + 错误文本;`usage --force` 重查后更新,见[用量查询](usage.md)) |
|
|
10
11
|
| `~/.config/systemd/user/flexgate.service` | 唯一的持久化服务 unit |
|
|
11
12
|
| systemd journal | 服务日志(`flexgate log`,即 `journalctl --user -u flexgate`) |
|
|
12
13
|
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
| `flexgate/config.py` | dataclass 配置模型(`GatewayConfig`、`ProviderConfig`、`RouteConfig` 等),YAML 加载/保存,`is_placeholder_key` |
|
|
10
10
|
| `flexgate/router.py` | `resolve(config, model)` — 先定时路由后默认路由,首个正则命中生效;匹配前归一化模型别名(`sonnet`/`claude-3-7-sonnet-latest` → `^claude-<tier>`) |
|
|
11
11
|
| `flexgate/proxy.py` | `handle_request()` — httpx 异步代理,per-key fallback 重试,SSE 流式 + JSON 透传;记录每个请求由哪把 key 服务 |
|
|
12
|
-
| `flexgate/usage.py` | `flexgate status` / `flexgate usage` 的用量查询 — 各平台适配器 + minimal chat probe 兜底 |
|
|
13
|
-
| `flexgate/server.py` | Starlette 应用,`POST /v1/messages` 端点,SIGUSR1 热重载 |
|
|
12
|
+
| `flexgate/usage.py` | `flexgate status` / `flexgate usage` 的用量查询 — 各平台适配器 + minimal chat probe 兜底 + 查询失败缓存(`usage --force` 强制重查) |
|
|
13
|
+
| `flexgate/server.py` | Starlette 应用,`POST /v1/messages` + `POST /v1/images/{generations,edits}` 端点,SIGUSR1 热重载 |
|
|
14
|
+
| `flexgate/images.py` | OpenAI Images API → MiniMax `/v1/image_generation` 翻译(模型名映射、size 钳制、`b64_json` 透传;edits 返回 501) |
|
|
15
|
+
| `flexgate/fallback.py` | 兜底路由:所有未服务路径返回 Anthropic/OpenAI 双兼容的结构化 JSON 错误(404 `route_not_found` / 405 / 501 `endpoint_not_supported`) |
|
|
16
|
+
| `flexgate/main.py` | 启动引导:加载配置 → 创建 app → 跑 uvicorn |
|
|
14
17
|
| `flexgate/service.py` | systemd 用户服务的安装/启停/重载/状态(持久化运行的唯一入口);journal 日志查看 |
|
|
15
18
|
| `flexgate/healthcheck.py` | 上游连通性探测(`POST /v1/messages`,`max_tokens=1`),由 `doctor` 调用(`flexgate check` 为弃用别名) |
|
|
16
19
|
| `flexgate/settings.py` | config.yaml ↔ `~/.claude/settings.json` 双向桥接(apply 非破坏性:只改托管 env 键) |
|
|
@@ -23,12 +23,17 @@ Claude Code → localhost:8765 → opus → z.ai (glm-5.1)
|
|
|
23
23
|
|
|
24
24
|
## 特性一览
|
|
25
25
|
|
|
26
|
-
- **按 model 路由**:正则匹配请求中的 model
|
|
26
|
+
- **按 model 路由**:正则匹配请求中的 model 字段,首个命中生效,支持兜底路由;
|
|
27
|
+
裸别名(`sonnet`/`opus`/`haiku`)与旧编号名自动归一化
|
|
27
28
|
- **定时路由**:按时间窗口自动切换路由(支持跨夜,如 22:00-06:00)
|
|
28
29
|
- **多 key fallback**:同一上游配置多个 key,遇到 401/402/403/429/5xx/529
|
|
29
30
|
或连接错误时自动切换到下一个 key
|
|
31
|
+
- **多模态降级**:非多模态模型的请求自动剥离图片块并注入说明,避免上游 4xx
|
|
32
|
+
- **OpenAI 兼容图像生成**:`POST /v1/images/generations` 自动转 MiniMax
|
|
33
|
+
`image-01`,详见 [HTTP API](api.md)
|
|
30
34
|
- **用量查询**:`flexgate status` / `flexgate usage` 按平台适配器查询每个
|
|
31
|
-
key 的实时额度(MiniMax、Kimi Code、z.ai、LiteLLM
|
|
35
|
+
key 的实时额度(MiniMax、Kimi Code、z.ai、LiteLLM 等);查询失败的 key
|
|
36
|
+
自动缓存跳过,`usage --force` 强制重查
|
|
32
37
|
- **Claude Code settings 管理**:import / apply 双向同步
|
|
33
38
|
- **配置同步**:`flexgate sync` 通过 confsync 服务器加密同步配置
|
|
34
39
|
- **自愈升级**:`flexgate doctor` 体检、`flexgate update` 一键升级并迁移配置
|
|
@@ -40,6 +45,7 @@ Claude Code → localhost:8765 → opus → z.ai (glm-5.1)
|
|
|
40
45
|
|
|
41
46
|
quickstart
|
|
42
47
|
configuration
|
|
48
|
+
api
|
|
43
49
|
cli
|
|
44
50
|
usage
|
|
45
51
|
development
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|------|----------|------|
|
|
9
9
|
| MiniMax(api.minimaxi.com / api.minimax.io) | `GET /v1/api/openplatform/coding_plan/remains`(Bearer 认证,复用 provider key) | 官方 token plan 接口,返回 5 小时窗口和周窗口的**剩余**次数(注意响应里 `*_usage_count` 字段实际是剩余量) |
|
|
10
10
|
| Kimi Code(api.kimi.com) | `GET {base}/v1/usages`(Bearer 认证,复用 provider key) | 未公开文档、与 Kimi Code CLI `/usage` 相同的接口,返回周配额、5 小时滚动窗口剩余量和并发上限 |
|
|
11
|
-
| z.ai / 智谱(api.z.ai / open.bigmodel.cn) | `GET /api/monitor/usage/quota/limit`(Authorization 直接带 key) | 未公开文档、但 z.ai 官方 coding
|
|
11
|
+
| z.ai / 智谱(api.z.ai / open.bigmodel.cn) | `GET /api/monitor/usage/quota/limit`(Authorization 直接带 key) | 未公开文档、但 z.ai 官方 coding 插件在用的接口,返回各窗口已用百分比与重置时间。**仅对 GLM Coding Plan 订阅有效**:普通按量付费 key 会返回"当前用户不存在coding plan"并退化为 probe(余额只能在网页控制台查看) |
|
|
12
12
|
| USTC(api.llm.ustc.edu.cn,LiteLLM) | `GET /key/info`(Bearer 认证) | LiteLLM proxy 自带的 key 信息接口,返回 spend / max_budget / 限速等 |
|
|
13
13
|
| 小米 MiMo(token-plan-cn.xiaomimimo.com) | **无 key 可用的官方接口**(控制台内部接口需要浏览器 cookie,不采用) | 退化为 minimal probe |
|
|
14
14
|
| 其他/未知平台 | minimal chat probe | 发一条输入 `"hi"`、`max_tokens=128` 的最小 `/v1/messages` 请求,验证 key 是否还能正常服务(会消耗极少量额度) |
|
|
@@ -20,15 +20,42 @@
|
|
|
20
20
|
用量查询接口多为平台内部接口,可能随时变动;查询结果仅供参考。
|
|
21
21
|
:::
|
|
22
22
|
|
|
23
|
+
## 查询失败缓存与 `--force`
|
|
24
|
+
|
|
25
|
+
用量查询中**报错的 key**(彻底失败,或专用接口报错但 probe 证明 key 仍
|
|
26
|
+
可用)会记录到 `~/.flexgate/usage-cache.json`(只存 key 的 sha256 指纹和
|
|
27
|
+
结果文本,不存 key 明文),此后 `flexgate status` / `flexgate usage`
|
|
28
|
+
默认**跳过**这些 key——不再发起任何网络请求,直接显示缓存的错误和一行
|
|
29
|
+
提示:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
Usage (timeout 15s per key)
|
|
33
|
+
2 key(s) skipped — last check failed (recheck with: flexgate usage --force)
|
|
34
|
+
zhipu
|
|
35
|
+
✓ key #1 (0b4d***MZTV) [fangyuan] cached (15s ago)
|
|
36
|
+
z.ai quota API (unofficial) failed (platform error: 当前用户不存在coding plan); probe: key can serve glm-5.3
|
|
37
|
+
recheck with: flexgate usage --force
|
|
38
|
+
ustc
|
|
39
|
+
✗ key #1 (sk-G***jJYg) cached failure (8s ago)
|
|
40
|
+
LiteLLM /key/info failed (ConnectError: ); probe: ConnectError:
|
|
41
|
+
recheck with: flexgate usage --force
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- 跳过的 key 按**上次结果**显示状态:key 仍可用的显示暗色 ✓ +
|
|
45
|
+
`cached (… ago)`,彻底失败的显示暗色 ✗ + `cached failure (… ago)`。
|
|
46
|
+
- `flexgate usage --force` 忽略缓存、重新查询所有 key 并更新缓存;
|
|
47
|
+
重查干净成功(无任何报错)的 key 会自动清除缓存条目,恢复默认实时查询。
|
|
48
|
+
- 占位符 key(未填写的默认值)不会进入缓存。
|
|
49
|
+
|
|
23
50
|
## 输出示例
|
|
24
51
|
|
|
25
52
|
```text
|
|
26
53
|
Usage (timeout 15s per key):
|
|
27
54
|
minimax:
|
|
28
|
-
✓ key #1 (sk-c***abcd) [main]
|
|
55
|
+
✓ key #1 (sk-c***abcd) [main] MiniMax coding_plan API
|
|
29
56
|
MiniMax-M3: 5h window remaining 450/450 (reset 08-24 00:00); weekly remaining 900/900 (reset 08-24 00:00)
|
|
30
57
|
kimi:
|
|
31
|
-
✓ key #1 (sk-k***66N6) [ywj]
|
|
58
|
+
✓ key #1 (sk-k***66N6) [ywj] Kimi Code usages API (unofficial)
|
|
32
59
|
plan: advanced
|
|
33
60
|
weekly remaining 63/100 (reset 08-28 22:17)
|
|
34
61
|
5h window remaining 74/100 (reset 08-23 14:17)
|
|
@@ -137,21 +137,30 @@ def cmd_usage(args: argparse.Namespace) -> None:
|
|
|
137
137
|
err(f"Config not found: {config_path} — run 'flexgate config init' to create one.", 1)
|
|
138
138
|
|
|
139
139
|
config = load_config(config_path)
|
|
140
|
-
_print_usage(config, getattr(args, "usage_timeout", 15.0))
|
|
140
|
+
_print_usage(config, getattr(args, "usage_timeout", 15.0), force=getattr(args, "force", False))
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def _print_usage(config, timeout: float) -> None:
|
|
144
|
-
from flexgate.usage import run_usage_check
|
|
143
|
+
def _print_usage(config, timeout: float, force: bool = False) -> None:
|
|
144
|
+
from flexgate.usage import FORCE_HINT, run_usage_check
|
|
145
145
|
|
|
146
146
|
print("\n" + _bold("Usage") + _dim(f" (timeout {timeout:g}s per key)"))
|
|
147
|
-
usage = run_usage_check(config, timeout=timeout)
|
|
147
|
+
usage = run_usage_check(config, timeout=timeout, force=force)
|
|
148
|
+
skipped = sum(1 for results in usage.values() for r in results if r.skipped)
|
|
149
|
+
if skipped:
|
|
150
|
+
print(_dim(f" {skipped} key(s) skipped — last check failed ({FORCE_HINT})"))
|
|
148
151
|
for name, results in usage.items():
|
|
149
152
|
print(f" {_bold(_cyan(name))}")
|
|
150
153
|
for r in results:
|
|
151
|
-
|
|
154
|
+
if r.skipped:
|
|
155
|
+
marker = _dim("✓") if r.ok else _dim("✗")
|
|
156
|
+
else:
|
|
157
|
+
marker = _green("✓") if r.ok else _red("✗")
|
|
152
158
|
print(f" {marker} {r.key_label} {_dim(r.method)}")
|
|
153
159
|
for line in r.lines:
|
|
154
|
-
|
|
160
|
+
if line == FORCE_HINT:
|
|
161
|
+
print(f" {_dim(line)}")
|
|
162
|
+
else:
|
|
163
|
+
print(f" {line if r.ok else _red(line)}")
|
|
155
164
|
|
|
156
165
|
|
|
157
166
|
# ── settings subcommands ────────────────────────────────────────────
|
|
@@ -213,6 +222,10 @@ def _set_route(
|
|
|
213
222
|
"""Update the default route for a pattern, or insert one before the catch-all."""
|
|
214
223
|
for route in config.routes:
|
|
215
224
|
if route.pattern.pattern == pattern:
|
|
225
|
+
if route.provider_name != provider_name:
|
|
226
|
+
# Stale active-key pointer from the old provider may point past
|
|
227
|
+
# the new provider's key list and make the config unloadable.
|
|
228
|
+
route.key_index = 0
|
|
216
229
|
route.provider_name = provider_name
|
|
217
230
|
route.model = model_override
|
|
218
231
|
return
|
|
@@ -1066,6 +1079,10 @@ def main() -> None:
|
|
|
1066
1079
|
"--usage-timeout", type=float, default=15.0,
|
|
1067
1080
|
help="Per-key usage query timeout in seconds (default: 15.0)"
|
|
1068
1081
|
)
|
|
1082
|
+
usage_p.add_argument(
|
|
1083
|
+
"--force", action="store_true",
|
|
1084
|
+
help="Recheck keys whose last usage check failed (skipped by default)"
|
|
1085
|
+
)
|
|
1069
1086
|
|
|
1070
1087
|
# flexgate log ...
|
|
1071
1088
|
log_p = sub.add_parser("log", help="Show gateway logs from the systemd journal")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Structured errors for every route and capability flexgate does not serve.
|
|
2
|
+
|
|
3
|
+
A bare Starlette 404 tells an API client nothing: it cannot tell "wrong port"
|
|
4
|
+
apart from "right gateway, unsupported endpoint". Every unmatched request
|
|
5
|
+
instead gets a JSON error explaining what was asked for and what flexgate
|
|
6
|
+
does support, so client-side fallback logic (and humans reading logs) can
|
|
7
|
+
react without guessing.
|
|
8
|
+
|
|
9
|
+
Error payload shape is a merge of the Anthropic and OpenAI conventions so
|
|
10
|
+
both client families parse it:
|
|
11
|
+
|
|
12
|
+
{"type": "error", "error": {"type": ..., "message": ..., "code": ...}}
|
|
13
|
+
|
|
14
|
+
Status code policy:
|
|
15
|
+
- 404 route_not_found unknown path entirely; message lists valid routes
|
|
16
|
+
- 405 method_not_allowed known path, wrong HTTP method
|
|
17
|
+
- 501 endpoint_not_supported recognized API family flexgate does not serve
|
|
18
|
+
- 501 capability_not_supported / model_not_supported for valid routes with
|
|
19
|
+
unsupported operations or model names (raised by the owning module)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from starlette.requests import Request
|
|
27
|
+
from starlette.responses import JSONResponse
|
|
28
|
+
|
|
29
|
+
SUPPORTED_ROUTES = ("POST /v1/messages", "POST /v1/images/generations")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def error_json(message: str, status: int, error_type: str, code: str) -> JSONResponse:
|
|
33
|
+
return JSONResponse(
|
|
34
|
+
{"type": "error", "error": {"type": error_type, "message": message, "code": code}},
|
|
35
|
+
status_code=status,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Recognized-but-unsupported API families: (path regex, human reason).
|
|
40
|
+
# Order matters; first match wins. Keep reasons actionable: say what the
|
|
41
|
+
# caller probably wanted and where that capability lives, if anywhere.
|
|
42
|
+
_UNSUPPORTED_FAMILIES: list[tuple[re.Pattern[str], str]] = [
|
|
43
|
+
(
|
|
44
|
+
re.compile(r"^/v1/messages/count_tokens$"),
|
|
45
|
+
"Token counting is not proxied; flexgate only forwards POST /v1/messages. "
|
|
46
|
+
"Count tokens client-side (e.g. with a tokenizer) instead.",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
re.compile(r"^/v1/(chat/completions|responses)(/.*)?$"),
|
|
50
|
+
"OpenAI chat is not served: flexgate proxies Anthropic-style POST /v1/messages only. "
|
|
51
|
+
"Point an Anthropic-compatible client at this gateway, or call the upstream provider directly.",
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
re.compile(r"^/v1/images/edits$"),
|
|
55
|
+
# Owning module (images.py) normally answers this first; kept as a safety net.
|
|
56
|
+
"Image editing/inpainting is not available: the MiniMax image-01 backend is text-to-image only. "
|
|
57
|
+
"Use POST /v1/images/generations, or edit the image with another tool and upload the result.",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
re.compile(r"^/v1/embeddings"),
|
|
61
|
+
"Embeddings are not proxied: no configured provider exposes an embedding endpoint. "
|
|
62
|
+
"Call an embedding-capable provider directly.",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
re.compile(r"^/v1/(audio|videos?)(/.*)?$"),
|
|
66
|
+
"Audio/video endpoints are not proxied by flexgate. "
|
|
67
|
+
"Call the upstream provider's native API directly.",
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
re.compile(r"^/v1/(models|files|fine_tuning|batches|moderations|vector_stores|assistants|threads|uploads)(/.*)?$"),
|
|
71
|
+
"This OpenAI platform endpoint is not proxied: flexgate is a routing gateway, not an API platform. "
|
|
72
|
+
"It only serves POST /v1/messages and POST /v1/images/generations.",
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def fallback(request: Request) -> JSONResponse:
|
|
78
|
+
"""Catch-all handler for every path/method flexgate does not serve."""
|
|
79
|
+
path = request.url.path.rstrip("/") or "/"
|
|
80
|
+
|
|
81
|
+
if path in ("/v1/messages", "/v1/images/generations"):
|
|
82
|
+
return error_json(
|
|
83
|
+
f"{request.method} is not allowed on {path}; use POST.",
|
|
84
|
+
405,
|
|
85
|
+
"invalid_request_error",
|
|
86
|
+
"method_not_allowed",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for pattern, reason in _UNSUPPORTED_FAMILIES:
|
|
90
|
+
if pattern.match(path):
|
|
91
|
+
return error_json(reason, 501, "not_implemented", "endpoint_not_supported")
|
|
92
|
+
|
|
93
|
+
return error_json(
|
|
94
|
+
f"Unknown route: {request.method} {path}. "
|
|
95
|
+
f"flexgate serves exactly: {', '.join(SUPPORTED_ROUTES)}.",
|
|
96
|
+
404,
|
|
97
|
+
"not_found_error",
|
|
98
|
+
"route_not_found",
|
|
99
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""OpenAI-compatible Images API endpoint, backed by MiniMax image generation.
|
|
2
|
+
|
|
3
|
+
Exposes `POST /v1/images/generations` so OpenAI-style clients (e.g. editppt's
|
|
4
|
+
openai-compatible-api image backend) can generate images through the gateway
|
|
5
|
+
without any per-provider configuration: the upstream is derived from whichever
|
|
6
|
+
configured provider points at MiniMax (`api.minimaxi.com` / `api.minimax.io`).
|
|
7
|
+
|
|
8
|
+
Translation notes:
|
|
9
|
+
- MiniMax only has text-to-image (`POST /v1/image_generation`, model
|
|
10
|
+
`image-01`). `size` ("WxH") is mapped to width/height clamped to 512-2048
|
|
11
|
+
and rounded to multiples of 8; unrecognized sizes are omitted.
|
|
12
|
+
- MiniMax replies with `data.image_base64[]` (JPEG bytes); it is forwarded
|
|
13
|
+
verbatim as OpenAI `data[].b64_json` — downstream tools sniff the real
|
|
14
|
+
content type, so no transcoding dependency is introduced here.
|
|
15
|
+
- `/v1/images/edits` is answered 501: MiniMax has no general image-editing or
|
|
16
|
+
inpainting capability (its subject_reference is character-only).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from starlette.requests import Request
|
|
29
|
+
from starlette.responses import JSONResponse
|
|
30
|
+
|
|
31
|
+
from flexgate.config import GatewayConfig, ProviderConfig
|
|
32
|
+
from flexgate.fallback import error_json
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("flexgate.images")
|
|
35
|
+
|
|
36
|
+
MINIMAX_MODEL = "image-01"
|
|
37
|
+
MINIMAX_MIN_EDGE = 512
|
|
38
|
+
MINIMAX_MAX_EDGE = 2048
|
|
39
|
+
MINIMAX_MAX_PROMPT_CHARS = 1500
|
|
40
|
+
|
|
41
|
+
# Requested model names containing any of these hints are accepted and mapped
|
|
42
|
+
# to MiniMax image-01 (the only image backend behind this gateway).
|
|
43
|
+
_IMAGE_MODEL_HINTS = (
|
|
44
|
+
"gpt-image",
|
|
45
|
+
"dall-e",
|
|
46
|
+
"image-01",
|
|
47
|
+
"seedream",
|
|
48
|
+
"flux",
|
|
49
|
+
"sdxl",
|
|
50
|
+
"stable-diffusion",
|
|
51
|
+
"minimax",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _error_json(message: str, status: int, code: str = "minimax_error") -> JSONResponse:
|
|
56
|
+
error_type = {
|
|
57
|
+
400: "invalid_request_error",
|
|
58
|
+
501: "not_implemented",
|
|
59
|
+
503: "not_implemented",
|
|
60
|
+
}.get(status, "api_error")
|
|
61
|
+
return error_json(message, status, error_type, code)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_minimax_provider(config: GatewayConfig) -> ProviderConfig | None:
|
|
65
|
+
candidates = [p for p in config.providers.values() if "minimax" in p.base_url]
|
|
66
|
+
for provider in candidates:
|
|
67
|
+
if provider.name == "minimax":
|
|
68
|
+
return provider
|
|
69
|
+
return candidates[0] if candidates else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _image_api_root(base_url: str) -> str:
|
|
73
|
+
parts = urlsplit(base_url)
|
|
74
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clamp_edge(value: int) -> int:
|
|
78
|
+
return max(MINIMAX_MIN_EDGE, min(MINIMAX_MAX_EDGE, value))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _round8(value: float) -> int:
|
|
82
|
+
return max(8, int(round(value / 8)) * 8)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _map_size(size: object) -> dict[str, int]:
|
|
86
|
+
if not isinstance(size, str):
|
|
87
|
+
return {}
|
|
88
|
+
match = re.fullmatch(r"(\d+)x(\d+)", size)
|
|
89
|
+
if not match:
|
|
90
|
+
return {}
|
|
91
|
+
w, h = int(match.group(1)), int(match.group(2))
|
|
92
|
+
if w <= 0 or h <= 0:
|
|
93
|
+
return {}
|
|
94
|
+
scale = min(1.0, MINIMAX_MAX_EDGE / max(w, h))
|
|
95
|
+
w, h = w * scale, h * scale
|
|
96
|
+
if min(w, h) < MINIMAX_MIN_EDGE:
|
|
97
|
+
boost = MINIMAX_MIN_EDGE / min(w, h)
|
|
98
|
+
w, h = w * boost, h * boost
|
|
99
|
+
if max(w, h) > MINIMAX_MAX_EDGE:
|
|
100
|
+
shrink = MINIMAX_MAX_EDGE / max(w, h)
|
|
101
|
+
w, h = w * shrink, h * shrink
|
|
102
|
+
return {"width": _clamp_edge(_round8(w)), "height": _clamp_edge(_round8(h))}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def images_generate(request: Request) -> JSONResponse:
|
|
106
|
+
t0 = time.monotonic()
|
|
107
|
+
config: GatewayConfig = request.app.state.config
|
|
108
|
+
client: httpx.AsyncClient = request.app.state.client
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
body = json.loads(await request.body())
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
return _error_json("Invalid JSON in request body", 400, "bad_request")
|
|
114
|
+
|
|
115
|
+
provider = _find_minimax_provider(config)
|
|
116
|
+
if provider is None:
|
|
117
|
+
return _error_json(
|
|
118
|
+
"No MiniMax provider configured (need a provider whose base_url points at MiniMax)",
|
|
119
|
+
503,
|
|
120
|
+
"provider_not_found",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
prompt = body.get("prompt")
|
|
124
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
125
|
+
return _error_json("Missing required field: prompt", 400, "bad_request")
|
|
126
|
+
|
|
127
|
+
requested_model = str(body.get("model") or "")
|
|
128
|
+
if requested_model and not any(h in requested_model.lower() for h in _IMAGE_MODEL_HINTS):
|
|
129
|
+
return _error_json(
|
|
130
|
+
f"Model '{requested_model}' is not a supported image model. flexgate maps image "
|
|
131
|
+
f"generation to MiniMax {MINIMAX_MODEL} (text-to-image only); request a gpt-image-* "
|
|
132
|
+
f"alias or '{MINIMAX_MODEL}'.",
|
|
133
|
+
501,
|
|
134
|
+
"model_not_supported",
|
|
135
|
+
)
|
|
136
|
+
logger.info(
|
|
137
|
+
"images/generations model=%s -> %s via %s",
|
|
138
|
+
requested_model or "(default)", MINIMAX_MODEL, provider.name,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
n = body.get("n", 1)
|
|
142
|
+
payload: dict = {
|
|
143
|
+
"model": MINIMAX_MODEL,
|
|
144
|
+
"prompt": prompt[:MINIMAX_MAX_PROMPT_CHARS],
|
|
145
|
+
"response_format": "base64",
|
|
146
|
+
"n": max(1, min(9, int(n))) if isinstance(n, (int, float)) else 1,
|
|
147
|
+
}
|
|
148
|
+
payload.update(_map_size(body.get("size")))
|
|
149
|
+
|
|
150
|
+
url = f"{_image_api_root(provider.base_url)}/v1/image_generation"
|
|
151
|
+
headers = {
|
|
152
|
+
"Authorization": f"Bearer {provider.api_key}",
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
158
|
+
except httpx.ConnectError as exc:
|
|
159
|
+
logger.error("Provider %s unreachable: %s", provider.name, exc)
|
|
160
|
+
return _error_json(f"Provider unreachable: {provider.name}", 502)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
data = resp.json()
|
|
164
|
+
except Exception:
|
|
165
|
+
return _error_json(resp.text[:500], 502)
|
|
166
|
+
|
|
167
|
+
base_resp = data.get("base_resp") or {}
|
|
168
|
+
status_code = base_resp.get("status_code", 0)
|
|
169
|
+
if resp.status_code != 200 or status_code != 0:
|
|
170
|
+
message = base_resp.get("status_msg") or f"HTTP {resp.status_code}"
|
|
171
|
+
logger.error("MiniMax image generation failed: %s", message)
|
|
172
|
+
return _error_json(f"MiniMax upstream: {message}", 502)
|
|
173
|
+
|
|
174
|
+
images = (data.get("data") or {}).get("image_base64") or []
|
|
175
|
+
if not images:
|
|
176
|
+
return _error_json("MiniMax returned no image_base64 payload", 502)
|
|
177
|
+
|
|
178
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
179
|
+
logger.info("images/generations <- %s | %d image(s) | %dms", provider.name, len(images), elapsed_ms)
|
|
180
|
+
return JSONResponse({
|
|
181
|
+
"created": int(time.time()),
|
|
182
|
+
"data": [{"b64_json": img, "revised_prompt": None} for img in images],
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def images_edits(request: Request) -> JSONResponse:
|
|
187
|
+
return _error_json(
|
|
188
|
+
"MiniMax image-01 does not support image editing/inpainting; "
|
|
189
|
+
"only /v1/images/generations (text-to-image) is available through this gateway.",
|
|
190
|
+
501,
|
|
191
|
+
"edits_not_supported",
|
|
192
|
+
)
|
|
@@ -13,6 +13,8 @@ from starlette.responses import JSONResponse
|
|
|
13
13
|
from starlette.routing import Route
|
|
14
14
|
|
|
15
15
|
from flexgate.config import GatewayConfig, load_config
|
|
16
|
+
from flexgate.fallback import fallback
|
|
17
|
+
from flexgate.images import images_edits, images_generate
|
|
16
18
|
from flexgate.proxy import handle_request
|
|
17
19
|
from flexgate.router import NoRouteMatchError, resolve
|
|
18
20
|
|
|
@@ -65,7 +67,17 @@ def create_app(config: GatewayConfig, config_path: str | None = None) -> Starlet
|
|
|
65
67
|
timeout=httpx.Timeout(connect=10, read=3600, write=10, pool=10),
|
|
66
68
|
)
|
|
67
69
|
|
|
68
|
-
routes = [
|
|
70
|
+
routes = [
|
|
71
|
+
Route("/v1/messages", messages, methods=["POST"]),
|
|
72
|
+
Route("/v1/images/generations", images_generate, methods=["POST"]),
|
|
73
|
+
Route("/v1/images/edits", images_edits, methods=["POST"]),
|
|
74
|
+
# Catch-all last: structured, reasoned errors for anything unsupported.
|
|
75
|
+
Route(
|
|
76
|
+
"/{path:path}",
|
|
77
|
+
fallback,
|
|
78
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
79
|
+
),
|
|
80
|
+
]
|
|
69
81
|
|
|
70
82
|
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
|
71
83
|
app.state.config = config
|
|
@@ -23,7 +23,10 @@ known adapter and queries it with the provider's own API key:
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
26
28
|
import logging
|
|
29
|
+
import os
|
|
27
30
|
import time
|
|
28
31
|
from dataclasses import dataclass, field
|
|
29
32
|
from datetime import datetime
|
|
@@ -31,7 +34,13 @@ from urllib.parse import urlparse
|
|
|
31
34
|
|
|
32
35
|
import httpx
|
|
33
36
|
|
|
34
|
-
from flexgate.config import
|
|
37
|
+
from flexgate.config import (
|
|
38
|
+
FLEXGATE_HOME,
|
|
39
|
+
ApiKey,
|
|
40
|
+
GatewayConfig,
|
|
41
|
+
ProviderConfig,
|
|
42
|
+
is_placeholder_key,
|
|
43
|
+
)
|
|
35
44
|
|
|
36
45
|
logger = logging.getLogger("flexgate.usage")
|
|
37
46
|
|
|
@@ -39,6 +48,11 @@ logger = logging.getLogger("flexgate.usage")
|
|
|
39
48
|
_PROBE_INPUT = "hi"
|
|
40
49
|
_PROBE_MAX_TOKENS = 128
|
|
41
50
|
|
|
51
|
+
# Keys whose usage check hard-failed are cached here and skipped by default
|
|
52
|
+
# until 'flexgate usage --force' rechecks them.
|
|
53
|
+
USAGE_CACHE_FILE = os.path.join(FLEXGATE_HOME, "usage-cache.json")
|
|
54
|
+
FORCE_HINT = "recheck with: flexgate usage --force"
|
|
55
|
+
|
|
42
56
|
|
|
43
57
|
@dataclass
|
|
44
58
|
class UsageResult:
|
|
@@ -47,6 +61,8 @@ class UsageResult:
|
|
|
47
61
|
method: str # how the information was obtained
|
|
48
62
|
ok: bool
|
|
49
63
|
lines: list[str] = field(default_factory=list)
|
|
64
|
+
skipped: bool = False # served from the failure cache, not queried now
|
|
65
|
+
adapter_error: str | None = None # adapter failed but the probe recovered
|
|
50
66
|
|
|
51
67
|
|
|
52
68
|
def _mask_key(key: str) -> str:
|
|
@@ -55,6 +71,49 @@ def _mask_key(key: str) -> str:
|
|
|
55
71
|
return key[:4] + "***" + key[-4:]
|
|
56
72
|
|
|
57
73
|
|
|
74
|
+
# ── failure cache ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fingerprint(key: str) -> str:
|
|
78
|
+
"""Stable identity for a key — the cache never stores key material."""
|
|
79
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_fail_cache() -> dict[str, dict]:
|
|
83
|
+
try:
|
|
84
|
+
with open(USAGE_CACHE_FILE) as f:
|
|
85
|
+
data = json.load(f)
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
return {}
|
|
88
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
89
|
+
return dict(entries) if isinstance(entries, dict) else {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _save_fail_cache(entries: dict[str, dict]) -> None:
|
|
93
|
+
tmp = USAGE_CACHE_FILE + ".tmp"
|
|
94
|
+
try:
|
|
95
|
+
with open(tmp, "w") as f:
|
|
96
|
+
json.dump({"entries": entries}, f, ensure_ascii=False, indent=2)
|
|
97
|
+
os.replace(tmp, USAGE_CACHE_FILE)
|
|
98
|
+
except OSError:
|
|
99
|
+
logger.debug("could not write usage cache", exc_info=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fmt_age(iso: str) -> str:
|
|
103
|
+
try:
|
|
104
|
+
then = datetime.fromisoformat(iso)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
return "?"
|
|
107
|
+
secs = max(0, (datetime.now() - then).total_seconds())
|
|
108
|
+
if secs < 60:
|
|
109
|
+
return f"{secs:.0f}s"
|
|
110
|
+
if secs < 3600:
|
|
111
|
+
return f"{secs / 60:.0f}m"
|
|
112
|
+
if secs < 86400:
|
|
113
|
+
return f"{secs / 3600:.0f}h"
|
|
114
|
+
return f"{secs / 86400:.0f}d"
|
|
115
|
+
|
|
116
|
+
|
|
58
117
|
def _origin(base_url: str) -> str:
|
|
59
118
|
parts = urlparse(base_url)
|
|
60
119
|
return f"{parts.scheme}://{parts.netloc}"
|
|
@@ -342,7 +401,10 @@ async def check_key_usage(
|
|
|
342
401
|
probe_lines, probe_err = await _probe_chat(client, provider, key, timeout)
|
|
343
402
|
note = f"{method} failed ({err}); probe: "
|
|
344
403
|
if probe_err is None:
|
|
345
|
-
return UsageResult(
|
|
404
|
+
return UsageResult(
|
|
405
|
+
provider.name, label, method, True, [note + probe_lines[0]],
|
|
406
|
+
adapter_error=err,
|
|
407
|
+
)
|
|
346
408
|
return UsageResult(provider.name, label, method, False, [note + probe_err])
|
|
347
409
|
|
|
348
410
|
lines, err = await _probe_chat(client, provider, key, timeout)
|
|
@@ -352,24 +414,82 @@ async def check_key_usage(
|
|
|
352
414
|
return UsageResult(provider.name, label, method, False, [err])
|
|
353
415
|
|
|
354
416
|
|
|
417
|
+
async def _check_key_cached(
|
|
418
|
+
client: httpx.AsyncClient,
|
|
419
|
+
provider: ProviderConfig,
|
|
420
|
+
entry: ApiKey,
|
|
421
|
+
index: int,
|
|
422
|
+
timeout: float,
|
|
423
|
+
view: dict[str, dict],
|
|
424
|
+
cache: dict[str, dict],
|
|
425
|
+
) -> tuple[UsageResult, bool]:
|
|
426
|
+
"""check_key_usage plus failure-cache bookkeeping.
|
|
427
|
+
|
|
428
|
+
`view` holds the failed checks to skip on this run (empty when forced);
|
|
429
|
+
`cache` is the live copy that gets written back. Any result carrying an
|
|
430
|
+
error (hard failure, or adapter error recovered by the probe) is cached
|
|
431
|
+
and skipped next time. Returns (result, cache_changed).
|
|
432
|
+
"""
|
|
433
|
+
key = entry.key
|
|
434
|
+
fp = _fingerprint(key)
|
|
435
|
+
hit = view.get(fp)
|
|
436
|
+
if hit is not None:
|
|
437
|
+
label = f"key #{index + 1} ({_mask_key(key)})"
|
|
438
|
+
if entry.note:
|
|
439
|
+
label += f" [{entry.note}]"
|
|
440
|
+
ok = bool(hit.get("ok"))
|
|
441
|
+
lines = list(hit.get("lines") or [hit.get("error") or "unknown error"])
|
|
442
|
+
age = _fmt_age(hit.get("failed_at", ""))
|
|
443
|
+
method = f"cached ({age} ago)" if ok else f"cached failure ({age} ago)"
|
|
444
|
+
return UsageResult(provider.name, label, method, ok, lines + [FORCE_HINT], skipped=True), False
|
|
445
|
+
result = await check_key_usage(client, provider, entry, index, timeout)
|
|
446
|
+
if is_placeholder_key(key):
|
|
447
|
+
return result, False # local config state, not a remote failure
|
|
448
|
+
if result.ok and result.adapter_error is None:
|
|
449
|
+
if fp in cache:
|
|
450
|
+
del cache[fp]
|
|
451
|
+
return result, True
|
|
452
|
+
return result, False
|
|
453
|
+
cache[fp] = {
|
|
454
|
+
"provider": provider.name,
|
|
455
|
+
"ok": result.ok,
|
|
456
|
+
"lines": [line[:400] for line in result.lines][:4],
|
|
457
|
+
"method": result.method,
|
|
458
|
+
"failed_at": datetime.now().isoformat(timespec="seconds"),
|
|
459
|
+
}
|
|
460
|
+
return result, True
|
|
461
|
+
|
|
462
|
+
|
|
355
463
|
async def check_all_usage(
|
|
356
|
-
config: GatewayConfig, timeout: float = 15.0
|
|
464
|
+
config: GatewayConfig, timeout: float = 15.0, force: bool = False
|
|
357
465
|
) -> dict[str, list[UsageResult]]:
|
|
358
|
-
"""Query usage for every key of every provider, concurrently.
|
|
466
|
+
"""Query usage for every key of every provider, concurrently.
|
|
467
|
+
|
|
468
|
+
Keys whose previous check hard-failed are served from the failure cache
|
|
469
|
+
(marked `skipped`) unless `force` is set; the cache is updated afterwards.
|
|
470
|
+
"""
|
|
359
471
|
results: dict[str, list[UsageResult]] = {}
|
|
472
|
+
cache = _load_fail_cache()
|
|
473
|
+
view: dict[str, dict] = {} if force else cache
|
|
360
474
|
async with httpx.AsyncClient() as client:
|
|
361
475
|
tasks = []
|
|
362
476
|
refs: list[tuple[str, int]] = []
|
|
363
477
|
for name, provider in config.providers.items():
|
|
364
478
|
results[name] = [None] * len(provider.api_keys) # type: ignore[list-item]
|
|
365
479
|
for index, entry in enumerate(provider.api_keys):
|
|
366
|
-
tasks.append(
|
|
480
|
+
tasks.append(_check_key_cached(client, provider, entry, index, timeout, view, cache))
|
|
367
481
|
refs.append((name, index))
|
|
368
482
|
done = await asyncio.gather(*tasks)
|
|
369
|
-
|
|
483
|
+
changed = False
|
|
484
|
+
for (name, index), (result, touched) in zip(refs, done):
|
|
370
485
|
results[name][index] = result
|
|
486
|
+
changed = changed or touched
|
|
487
|
+
if changed:
|
|
488
|
+
_save_fail_cache(cache)
|
|
371
489
|
return results
|
|
372
490
|
|
|
373
491
|
|
|
374
|
-
def run_usage_check(
|
|
375
|
-
|
|
492
|
+
def run_usage_check(
|
|
493
|
+
config: GatewayConfig, timeout: float = 15.0, force: bool = False
|
|
494
|
+
) -> dict[str, list[UsageResult]]:
|
|
495
|
+
return asyncio.run(check_all_usage(config, timeout, force=force))
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|