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.
Files changed (41) hide show
  1. {flexgate-0.6.0 → flexgate-0.8.0}/CLAUDE.md +9 -3
  2. {flexgate-0.6.0 → flexgate-0.8.0}/PKG-INFO +38 -6
  3. {flexgate-0.6.0 → flexgate-0.8.0}/README.md +37 -5
  4. flexgate-0.8.0/docs/api.md +66 -0
  5. {flexgate-0.6.0 → flexgate-0.8.0}/docs/cli.md +6 -1
  6. {flexgate-0.6.0 → flexgate-0.8.0}/docs/conf.py +3 -0
  7. {flexgate-0.6.0 → flexgate-0.8.0}/docs/configuration.md +1 -0
  8. {flexgate-0.6.0 → flexgate-0.8.0}/docs/development.md +5 -2
  9. {flexgate-0.6.0 → flexgate-0.8.0}/docs/index.md +8 -2
  10. {flexgate-0.6.0 → flexgate-0.8.0}/docs/usage.md +30 -3
  11. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/__init__.py +1 -1
  12. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/cli.py +23 -6
  13. flexgate-0.8.0/flexgate/fallback.py +99 -0
  14. flexgate-0.8.0/flexgate/images.py +192 -0
  15. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/server.py +13 -1
  16. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/usage.py +128 -8
  17. {flexgate-0.6.0 → flexgate-0.8.0}/.github/workflows/docs.yml +0 -0
  18. {flexgate-0.6.0 → flexgate-0.8.0}/.github/workflows/release.yml +0 -0
  19. {flexgate-0.6.0 → flexgate-0.8.0}/.gitignore +0 -0
  20. {flexgate-0.6.0 → flexgate-0.8.0}/config.yaml.template +0 -0
  21. {flexgate-0.6.0 → flexgate-0.8.0}/docs/Makefile +0 -0
  22. {flexgate-0.6.0 → flexgate-0.8.0}/docs/_static/.gitkeep +0 -0
  23. {flexgate-0.6.0 → flexgate-0.8.0}/docs/quickstart.md +0 -0
  24. {flexgate-0.6.0 → flexgate-0.8.0}/docs/requirements.txt +0 -0
  25. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/__main__.py +0 -0
  26. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/config.py +0 -0
  27. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/doctor.py +0 -0
  28. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/healthcheck.py +0 -0
  29. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/main.py +0 -0
  30. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/migrate.py +0 -0
  31. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/proxy.py +0 -0
  32. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/router.py +0 -0
  33. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/service.py +0 -0
  34. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/settings.py +0 -0
  35. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/sync.py +0 -0
  36. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/ui.py +0 -0
  37. {flexgate-0.6.0 → flexgate-0.8.0}/flexgate/update.py +0 -0
  38. {flexgate-0.6.0 → flexgate-0.8.0}/pyproject.toml +0 -0
  39. {flexgate-0.6.0 → flexgate-0.8.0}/test.png +0 -0
  40. {flexgate-0.6.0 → flexgate-0.8.0}/test_multimodal.py +0 -0
  41. {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, single route)
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` endpoint, `SIGUSR1` lifespan reload |
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.6.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 # 跳过 PyPI 检查
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 tier · s save · q quit
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),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
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
- - 请求先走第一个 key;失败时按列表顺序自动重试后续 key,直到某个 key
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 # 跳过 PyPI 检查
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 tier · s save · q quit
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),回车进入:先从候选 **provider** 列表选择,再从该 provider 的候选 **model** 列表选择。
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
- - 请求先走第一个 key;失败时按列表顺序自动重试后续 key,直到某个 key
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
- 各平台的用量查询适配器详见[用量查询](usage.md)。
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] [MiniMax coding_plan API]
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] [Kimi Code usages API (unofficial)]
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)
@@ -1,3 +1,3 @@
1
1
  """Flexgate — Flexible API Gateway for Claude Code."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.8.0"
@@ -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
- marker = _green("✓") if r.ok else _red("✗")
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
- print(f" {line if r.ok else _red(line)}")
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 = [Route("/v1/messages", messages, methods=["POST"])]
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 ApiKey, GatewayConfig, ProviderConfig, is_placeholder_key
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(provider.name, label, method, True, [note + probe_lines[0]])
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(check_key_usage(client, provider, entry, index, timeout))
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
- for (name, index), result in zip(refs, done):
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(config: GatewayConfig, timeout: float = 15.0) -> dict[str, list[UsageResult]]:
375
- return asyncio.run(check_all_usage(config, timeout))
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