flexgate 0.8.0__tar.gz → 0.9.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.8.0 → flexgate-0.9.0}/CLAUDE.md +3 -3
  2. {flexgate-0.8.0 → flexgate-0.9.0}/PKG-INFO +13 -2
  3. {flexgate-0.8.0 → flexgate-0.9.0}/README.md +12 -1
  4. {flexgate-0.8.0 → flexgate-0.9.0}/config.yaml.template +12 -0
  5. {flexgate-0.8.0 → flexgate-0.9.0}/docs/configuration.md +12 -1
  6. {flexgate-0.8.0 → flexgate-0.9.0}/docs/usage.md +13 -6
  7. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/__init__.py +1 -1
  8. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/config.py +54 -8
  9. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/usage.py +101 -10
  10. {flexgate-0.8.0 → flexgate-0.9.0}/.github/workflows/docs.yml +0 -0
  11. {flexgate-0.8.0 → flexgate-0.9.0}/.github/workflows/release.yml +0 -0
  12. {flexgate-0.8.0 → flexgate-0.9.0}/.gitignore +0 -0
  13. {flexgate-0.8.0 → flexgate-0.9.0}/docs/Makefile +0 -0
  14. {flexgate-0.8.0 → flexgate-0.9.0}/docs/_static/.gitkeep +0 -0
  15. {flexgate-0.8.0 → flexgate-0.9.0}/docs/api.md +0 -0
  16. {flexgate-0.8.0 → flexgate-0.9.0}/docs/cli.md +0 -0
  17. {flexgate-0.8.0 → flexgate-0.9.0}/docs/conf.py +0 -0
  18. {flexgate-0.8.0 → flexgate-0.9.0}/docs/development.md +0 -0
  19. {flexgate-0.8.0 → flexgate-0.9.0}/docs/index.md +0 -0
  20. {flexgate-0.8.0 → flexgate-0.9.0}/docs/quickstart.md +0 -0
  21. {flexgate-0.8.0 → flexgate-0.9.0}/docs/requirements.txt +0 -0
  22. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/__main__.py +0 -0
  23. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/cli.py +0 -0
  24. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/doctor.py +0 -0
  25. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/fallback.py +0 -0
  26. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/healthcheck.py +0 -0
  27. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/images.py +0 -0
  28. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/main.py +0 -0
  29. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/migrate.py +0 -0
  30. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/proxy.py +0 -0
  31. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/router.py +0 -0
  32. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/server.py +0 -0
  33. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/service.py +0 -0
  34. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/settings.py +0 -0
  35. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/sync.py +0 -0
  36. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/ui.py +0 -0
  37. {flexgate-0.8.0 → flexgate-0.9.0}/flexgate/update.py +0 -0
  38. {flexgate-0.8.0 → flexgate-0.9.0}/pyproject.toml +0 -0
  39. {flexgate-0.8.0 → flexgate-0.9.0}/test.png +0 -0
  40. {flexgate-0.8.0 → flexgate-0.9.0}/test_multimodal.py +0 -0
  41. {flexgate-0.8.0 → flexgate-0.9.0}/test_multimodal_gateway.py +0 -0
@@ -92,7 +92,7 @@ OpenAI-style client → POST /v1/images/generations
92
92
  | `config.py` | Pydantic-like dataclasses (`GatewayConfig`, `ProviderConfig`, `RouteConfig`, `ScheduleEntry`), YAML load/save, `TIER_PATTERNS` regex map, `is_placeholder_key` |
93
93
  | `router.py` | `resolve(config, model)` — schedule-first then default routes, first regex match wins; model aliases normalized before matching |
94
94
  | `proxy.py` | `handle_request()` — httpx async proxy, per-key fallback retry loop, SSE streaming + JSON pass-through |
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 |
95
+ | `usage.py` | `flexgate status` usage queries — per-platform adapters (MiniMax coding_plan API, Kimi Code usages API, z.ai quota API, zhipu team quota API, LiteLLM `/key/info`) plus a minimal chat probe fallback |
96
96
  | `server.py` | Starlette app creation, `POST /v1/messages` + `POST /v1/images/{generations,edits}` endpoints, `SIGUSR1` lifespan reload |
97
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
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 |
@@ -111,8 +111,8 @@ OpenAI-style client → POST /v1/images/generations
111
111
  - **Model alias normalization** (`router.py`): before matching, bare aliases (`sonnet`/`opus`/`haiku`/`default`, case-insensitive, optional `[plan]` suffix) map to `claude-<tier>` prefixes (`default` → sonnet, Claude Code's default tier), and legacy numbered names (`claude-3-7-sonnet-latest`) reduce to their tier prefix; everything else passes through unchanged, so custom patterns and the catch-all behave exactly as before.
112
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.
113
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.
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.
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.
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 — plus optional `organization`/`project` on zhipu team keys, used only by the usage check; `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.
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 → `{origin}/api/monitor/usage/quota/limit` (undocumented but used by z.ai's own plugin; personal coding plans only), bigmodel.cn → the same endpoint with `?type=2` plus `bigmodel-organization`/`bigmodel-project` headers read from the key entry's `organization`/`project` fields (team plans; also fetches the team subscription detail for plan name/expiry — a key entry without both fields gets no adapter at all and goes straight to the probe, with a hint line), 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.
116
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.
117
117
  - **Schedule-based overrides**: Optional time windows (e.g. 22:00-06:00) override default routes. Overnight wrap is supported.
118
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.8.0
3
+ Version: 0.9.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
@@ -337,6 +337,13 @@ providers:
337
337
  - key: "your-minimax-api-key-2"
338
338
  note: "备用账号"
339
339
  - "your-minimax-api-key-3" # 纯字符串写法(无备注)
340
+ zhipu-team:
341
+ base_url: "https://open.bigmodel.cn/api/anthropic"
342
+ api_keys:
343
+ - key: "your-zhipu-team-api-key"
344
+ note: "队友名"
345
+ organization: "org-XXXXXXXX" # 智谱团队版 key:组织/项目 ID
346
+ project: "proj_XXXXXXXX" # (flexgate usage 查团队套餐额度用)
340
347
 
341
348
  claude_settings:
342
349
  default_opus_model: "claude-opus-4-7"
@@ -376,7 +383,7 @@ routes: # 从上到下匹配,首个命中生效
376
383
  |------|------|
377
384
  | `server.host/port` | 网关监听地址 |
378
385
  | `providers.<name>.base_url` | Provider 的 API 地址 |
379
- | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
386
+ | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`(`note` 是备注)。智谱团队版 key 还可加 `organization`/`project`(团队组织/项目 ID),供 `flexgate usage` 查询团队套餐额度 |
380
387
  | `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
381
388
  | `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
382
389
  | `routes[].pattern` | 正则匹配请求中的 model 字段(匹配前归一化裸别名 `sonnet`/`opus`/`haiku`/`default` 与旧编号名) |
@@ -410,6 +417,10 @@ routes: # 从上到下匹配,首个命中生效
410
417
  - 每次切换都会在服务日志中留下记录(key 只显示前后各 4 位)。
411
418
  - 用 `flexgate status` 可以查看每个 provider 的 fallback 链和每个 key 的
412
419
  实时用量。
420
+ - 智谱 GLM Coding Plan **团队版** key 在条目上配 `organization`/`project`
421
+ (团队组织/项目 ID)后,`flexgate status`/`usage` 会用团队版接口查询
422
+ 套餐名、有效期和 5 小时/每周积分窗口;未配置这两个字段的 key 只做
423
+ 连通性探测。
413
424
  - 旧版 `api_key` + `fallback_keys` 写法仍然兼容,`flexgate update` 会
414
425
  自动迁移为 `api_keys` 列表(config_version 3 → 4)。
415
426
 
@@ -320,6 +320,13 @@ providers:
320
320
  - key: "your-minimax-api-key-2"
321
321
  note: "备用账号"
322
322
  - "your-minimax-api-key-3" # 纯字符串写法(无备注)
323
+ zhipu-team:
324
+ base_url: "https://open.bigmodel.cn/api/anthropic"
325
+ api_keys:
326
+ - key: "your-zhipu-team-api-key"
327
+ note: "队友名"
328
+ organization: "org-XXXXXXXX" # 智谱团队版 key:组织/项目 ID
329
+ project: "proj_XXXXXXXX" # (flexgate usage 查团队套餐额度用)
323
330
 
324
331
  claude_settings:
325
332
  default_opus_model: "claude-opus-4-7"
@@ -359,7 +366,7 @@ routes: # 从上到下匹配,首个命中生效
359
366
  |------|------|
360
367
  | `server.host/port` | 网关监听地址 |
361
368
  | `providers.<name>.base_url` | Provider 的 API 地址 |
362
- | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
369
+ | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`(`note` 是备注)。智谱团队版 key 还可加 `organization`/`project`(团队组织/项目 ID),供 `flexgate usage` 查询团队套餐额度 |
363
370
  | `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
364
371
  | `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
365
372
  | `routes[].pattern` | 正则匹配请求中的 model 字段(匹配前归一化裸别名 `sonnet`/`opus`/`haiku`/`default` 与旧编号名) |
@@ -393,6 +400,10 @@ routes: # 从上到下匹配,首个命中生效
393
400
  - 每次切换都会在服务日志中留下记录(key 只显示前后各 4 位)。
394
401
  - 用 `flexgate status` 可以查看每个 provider 的 fallback 链和每个 key 的
395
402
  实时用量。
403
+ - 智谱 GLM Coding Plan **团队版** key 在条目上配 `organization`/`project`
404
+ (团队组织/项目 ID)后,`flexgate status`/`usage` 会用团队版接口查询
405
+ 套餐名、有效期和 5 小时/每周积分窗口;未配置这两个字段的 key 只做
406
+ 连通性探测。
396
407
  - 旧版 `api_key` + `fallback_keys` 写法仍然兼容,`flexgate update` 会
397
408
  自动迁移为 `api_keys` 列表(config_version 3 → 4)。
398
409
 
@@ -8,6 +8,9 @@ server:
8
8
  # 日志都会显示)。某个 key 遇到 401/402/403/429/5xx/529 或连接错误(如额度
9
9
  # 用尽、平台过载)时,按顺序自动切换到下一个 key 重试。同一上游的多个账号
10
10
  # 应这样配置,而不是拆成多个 provider。
11
+ # 智谱 GLM Coding Plan 团队版 key 还需在 key 条目上配置 organization 和
12
+ # project(团队组织/项目 ID,从团队用量页的浏览器请求头里一次性获取);
13
+ # flexgate usage 依赖这两个字段查询团队套餐额度,未配置则只做连通性探测。
11
14
  providers:
12
15
  minimax:
13
16
  base_url: "https://api.minimaxi.com/anthropic"
@@ -26,6 +29,15 @@ providers:
26
29
  available_models:
27
30
  - "glm-5.3" # 路由省略 model 时的回退模型
28
31
  - "glm-4.6v"
32
+ zhipu-team:
33
+ base_url: "https://open.bigmodel.cn/api/anthropic"
34
+ api_keys:
35
+ - key: "your-zhipu-team-api-key"
36
+ note: "队友名"
37
+ organization: "org-XXXXXXXX" # 智谱团队版:组织 ID
38
+ project: "proj_XXXXXXXX" # 智谱团队版:项目 ID
39
+ available_models:
40
+ - "glm-5.3"
29
41
  xiaomi:
30
42
  base_url: "https://token-plan-cn.xiaomimimo.com/anthropic"
31
43
  api_keys:
@@ -37,6 +37,13 @@ providers:
37
37
  - key: "your-minimax-api-key-2"
38
38
  note: "备用账号"
39
39
  - "your-minimax-api-key-3" # 纯字符串写法(无备注)
40
+ zhipu-team:
41
+ base_url: "https://open.bigmodel.cn/api/anthropic"
42
+ api_keys:
43
+ - key: "your-zhipu-team-api-key"
44
+ note: "队友名"
45
+ organization: "org-XXXXXXXX" # 智谱团队版 key:组织/项目 ID
46
+ project: "proj_XXXXXXXX" # (flexgate usage 查团队套餐额度用)
40
47
 
41
48
  claude_settings:
42
49
  default_opus_model: "claude-opus-4-7"
@@ -76,7 +83,7 @@ routes: # 从上到下匹配,首个命中生效
76
83
  |------|------|
77
84
  | `server.host/port` | 网关监听地址 |
78
85
  | `providers.<name>.base_url` | Provider 的 API 地址 |
79
- | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`,`note` 是存在配置里的备注 |
86
+ | `providers.<name>.api_keys` | API key 列表(一个或多个,互为 fallback);每项为 key 字符串或 `{key, note}`(`note` 是备注)。智谱团队版 key 还可加 `organization`/`project`(团队组织/项目 ID),供 `flexgate usage` 查询团队套餐额度 |
80
87
  | `providers.<name>.available_models` | 该 provider 的可用模型列表,首个条目作为路由省略 `model` 时的回退模型 |
81
88
  | `claude_settings.*` | 写入 settings.json 的模型和超时配置 |
82
89
  | `routes[].pattern` | 正则匹配请求中的 model 字段 |
@@ -109,6 +116,10 @@ routes: # 从上到下匹配,首个命中生效
109
116
  当前使用的 key 和每个 key 的实时用量;在 `flexgate config edit` 交互
110
117
  界面的「api keys」入口可以按路由查看各 key 的实时用量/有效性并切换
111
118
  active key。
119
+ - 智谱 GLM Coding Plan **团队版** key 在条目上配 `organization`/`project`
120
+ (团队组织/项目 ID)后,`flexgate status`/`usage` 会用团队版接口查询
121
+ 套餐名、有效期和 5 小时/每周积分窗口;未配置这两个字段的 key 只做
122
+ 连通性探测(团队 key 不适用个人版查询接口)。
112
123
  - 旧版 `api_key` + `fallback_keys` 写法仍然兼容,`flexgate update` 会
113
124
  自动迁移为 `api_keys` 列表(config_version 3 → 4);路由上的
114
125
  `active_key` 字段在 config_version 5 引入,旧配置的每条路由默认指向
@@ -8,7 +8,8 @@
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 插件在用的接口,返回各窗口已用百分比与重置时间。**仅对 GLM Coding Plan 订阅有效**:普通按量付费 key 会返回"当前用户不存在coding plan"并退化为 probe(余额只能在网页控制台查看) |
11
+ | z.ai 个人版(api.z.ai) | `GET /api/monitor/usage/quota/limit`(Authorization 直接带 key) | 未公开文档、但 z.ai 官方 coding 插件在用的接口,返回各窗口已用百分比与重置时间。**仅对 GLM Coding Plan 个人订阅有效**:按量付费 key 会返回"当前用户不存在coding plan"并退化为 probe |
12
+ | 智谱团队版(open.bigmodel.cn) | `GET /api/monitor/usage/quota/limit?type=2`,带 `bigmodel-organization`/`bigmodel-project` 头(取自 key 条目的 `organization`/`project` 字段) | 同一 monitor 接口的团队版变体,返回 5 小时/每周窗口的积分用量(绝对值 + 百分比 + 重置时间);另查 `querySubscribeDetail` 显示套餐名与有效期。**key 条目未配 `organization`/`project` 时不查询**,退化为 probe(团队 key 不适用个人版接口) |
12
13
  | USTC(api.llm.ustc.edu.cn,LiteLLM) | `GET /key/info`(Bearer 认证) | LiteLLM proxy 自带的 key 信息接口,返回 spend / max_budget / 限速等 |
13
14
  | 小米 MiMo(token-plan-cn.xiaomimimo.com) | **无 key 可用的官方接口**(控制台内部接口需要浏览器 cookie,不采用) | 退化为 minimal probe |
14
15
  | 其他/未知平台 | minimal chat probe | 发一条输入 `"hi"`、`max_tokens=128` 的最小 `/v1/messages` 请求,验证 key 是否还能正常服务(会消耗极少量额度) |
@@ -31,8 +32,8 @@
31
32
  ```text
32
33
  Usage (timeout 15s per key)
33
34
  2 key(s) skipped — last check failed (recheck with: flexgate usage --force)
34
- zhipu
35
- ✓ key #1 (0b4d***MZTV) [fangyuan] cached (15s ago)
35
+ zai
36
+ ✓ key #1 (9f2c***abCD) cached (15s ago)
36
37
  z.ai quota API (unofficial) failed (platform error: 当前用户不存在coding plan); probe: key can serve glm-5.3
37
38
  recheck with: flexgate usage --force
38
39
  ustc
@@ -60,11 +61,17 @@ Usage (timeout 15s per key):
60
61
  weekly remaining 63/100 (reset 08-28 22:17)
61
62
  5h window remaining 74/100 (reset 08-23 14:17)
62
63
  parallel limit: 30
64
+ zhipu-team:
65
+ ✓ key #2 (ddb6***oZoj) [zhangsiyi] zhipu team quota API (unofficial)
66
+ plan: 团队套餐高级版 (until 2026-10-14, auto-renew off)
67
+ 5h window: used 264/35000 credits (1%), reset 09-25 14:00
68
+ weekly window: used 93043/155000 credits (60%), reset 09-28 16:17
63
69
  ```
64
70
 
65
71
  ## 新增平台适配器
66
72
 
67
73
  适配器登记在 `flexgate/usage.py` 的 `_ADAPTERS` 列表中:每条是
68
- `(url 子串, 适配器协程, 方法标签)`,首个命中生效。已知没有 key 可用接口的
69
- 平台放入 `_PROBE_ONLY_MARKERS`,直接走 probe,避免浪费一次失败的 HTTP
70
- 请求。
74
+ `(url 子串, 适配器协程, 方法标签)`,首个命中生效。bigmodel.cn 的团队版
75
+ 适配器额外要求 key 条目上配置 `organization`/`project`,否则视为无适配
76
+ 接口。已知没有 key 可用接口的平台放入 `_PROBE_ONLY_MARKERS`,直接走
77
+ probe,避免浪费一次失败的 HTTP 请求。
@@ -1,3 +1,3 @@
1
1
  """Flexgate — Flexible API Gateway for Claude Code."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -35,9 +35,16 @@ def is_placeholder_key(key: str) -> bool:
35
35
 
36
36
  @dataclass
37
37
  class ApiKey:
38
- """One API key for a provider, with an optional human-readable note."""
38
+ """One API key for a provider, with an optional human-readable note.
39
+
40
+ ``organization``/``project`` identify the team a zhipu (bigmodel.cn)
41
+ GLM Coding Plan *team* key belongs to; the usage check passes them as the
42
+ bigmodel-organization/bigmodel-project headers of the team quota query.
43
+ """
39
44
  key: str
40
45
  note: str = ""
46
+ organization: str = ""
47
+ project: str = ""
41
48
 
42
49
 
43
50
  @dataclass
@@ -122,7 +129,9 @@ def _parse_api_keys(name: str, prov: dict) -> list[ApiKey]:
122
129
  """Parse a provider's key list.
123
130
 
124
131
  Current schema: ``api_keys`` — a list whose entries are either a plain
125
- key string or a mapping ``{key: ..., note: ...}``. The legacy schema
132
+ key string or a mapping ``{key: ..., note: ..., organization: ...,
133
+ project: ...}`` (``note`` free-form; ``organization``/``project`` are the
134
+ zhipu team IDs used by the usage check). The legacy schema
126
135
  (``api_key`` plus optional ``fallback_keys``) is still accepted.
127
136
  """
128
137
  raw = prov.get("api_keys")
@@ -136,7 +145,19 @@ def _parse_api_keys(name: str, prov: dict) -> list[ApiKey]:
136
145
  if isinstance(item, str):
137
146
  keys.append(ApiKey(key=item))
138
147
  elif isinstance(item, dict) and isinstance(item.get("key"), str):
139
- keys.append(ApiKey(key=item["key"], note=str(item.get("note") or "")))
148
+ organization = str(item.get("organization") or "")
149
+ project = str(item.get("project") or "")
150
+ if bool(organization) != bool(project):
151
+ raise ValueError(
152
+ f"Provider '{name}': api_keys entry '{item['key'][:4]}***' sets "
153
+ f"only one of 'organization'/'project' — set both or neither"
154
+ )
155
+ keys.append(ApiKey(
156
+ key=item["key"],
157
+ note=str(item.get("note") or ""),
158
+ organization=organization,
159
+ project=project,
160
+ ))
140
161
  else:
141
162
  raise ValueError(
142
163
  f"Provider '{name}': each api_keys entry must be a key string "
@@ -262,6 +283,21 @@ def _format_hhmm(minutes: int) -> str:
262
283
  return f"{minutes // 60:02d}:{minutes % 60:02d}"
263
284
 
264
285
 
286
+ def _serialize_key(k: ApiKey) -> dict | str:
287
+ """Key entry → YAML value: a plain string, or a mapping that keeps the
288
+ note and the zhipu-team organization/project fields."""
289
+ if not (k.note or k.organization or k.project):
290
+ return k.key
291
+ data: dict = {"key": k.key}
292
+ if k.note:
293
+ data["note"] = k.note
294
+ if k.organization:
295
+ data["organization"] = k.organization
296
+ if k.project:
297
+ data["project"] = k.project
298
+ return data
299
+
300
+
265
301
  def save_config(cfg: GatewayConfig, path: str | None = None) -> None:
266
302
  import yaml
267
303
 
@@ -287,10 +323,7 @@ def save_config(cfg: GatewayConfig, path: str | None = None) -> None:
287
323
  for name, prov in cfg.providers.items():
288
324
  entry: dict = {
289
325
  "base_url": prov.base_url,
290
- "api_keys": [
291
- {"key": k.key, "note": k.note} if k.note else k.key
292
- for k in prov.api_keys
293
- ],
326
+ "api_keys": [_serialize_key(k) for k in prov.api_keys],
294
327
  }
295
328
  if prov.available_models:
296
329
  entry["available_models"] = list(prov.available_models)
@@ -329,7 +362,11 @@ server:
329
362
  # a plain key string or {key, note} where `note` is a free-form label stored
330
363
  # in the config (shown by `flexgate status`, included in fallback logs).
331
364
  # Multiple accounts on the same upstream should be configured this way
332
- # instead of as separate providers. A route's `active_key` (see below)
365
+ # instead of as separate providers. zhipu GLM Coding Plan *team* keys also
366
+ # carry `organization`/`project` — the team IDs copied once from the team
367
+ # usage page's request headers; `flexgate usage` needs them to query the
368
+ # team plan quota, and without them the key only gets an availability probe.
369
+ # A route's `active_key` (see below)
333
370
  # selects which key its requests start from; when that key fails with a
334
371
  # retryable error (401/402/403/429/5xx/529 or a connection error — e.g.
335
372
  # quota exhausted or provider overloaded), the request is retried with the
@@ -353,6 +390,15 @@ providers:
353
390
  available_models:
354
391
  - "glm-5.3" # used as fallback when a route omits `model`
355
392
  - "glm-4.6v"
393
+ zhipu-team:
394
+ base_url: "https://open.bigmodel.cn/api/anthropic"
395
+ api_keys:
396
+ - key: "your-zhipu-team-api-key"
397
+ note: "teammate name"
398
+ organization: "org-XXXXXXXX" # zhipu team IDs (see comment above)
399
+ project: "proj_XXXXXXXX"
400
+ available_models:
401
+ - "glm-5.3"
356
402
  xiaomi:
357
403
  base_url: "https://token-plan-cn.xiaomimimo.com/anthropic"
358
404
  api_keys:
@@ -14,6 +14,11 @@ known adapter and queries it with the provider's own API key:
14
14
  returns weekly quota, 5-hour window and parallel limit)
15
15
  * z.ai → GET {origin}/api/monitor/usage/quota/limit
16
16
  (undocumented but used by z.ai's own coding plugin)
17
+ * zhipu team→ GET {origin}/api/monitor/usage/quota/limit?type=2 with
18
+ bigmodel-organization/bigmodel-project headers taken from the
19
+ key entry's organization/project fields, plus the team
20
+ subscription detail for the plan name/expiry; keys without
21
+ both fields cannot be queried and get the plain probe
17
22
  * LiteLLM → GET {origin}/key/info (e.g. USTC api.llm.ustc.edu.cn)
18
23
  * anything else (e.g. Xiaomi MiMo, which has no key-based usage API)
19
24
  → minimal chat probe: POST /v1/messages with input "hi" and
@@ -176,8 +181,9 @@ async def _get_json(
176
181
 
177
182
 
178
183
  async def _usage_minimax(
179
- client: httpx.AsyncClient, provider: ProviderConfig, key: str, timeout: float
184
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
180
185
  ) -> tuple[list[str], str | None]:
186
+ key = entry.key
181
187
  url = f"{_origin(provider.base_url)}/v1/api/openplatform/coding_plan/remains"
182
188
  headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
183
189
  data, err = await _get_json(client, url, headers, timeout)
@@ -217,8 +223,9 @@ async def _usage_minimax(
217
223
 
218
224
 
219
225
  async def _usage_zai(
220
- client: httpx.AsyncClient, provider: ProviderConfig, key: str, timeout: float
226
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
221
227
  ) -> tuple[list[str], str | None]:
228
+ key = entry.key
222
229
  url = f"{_origin(provider.base_url)}/api/monitor/usage/quota/limit"
223
230
  headers = {"Authorization": key, "Content-Type": "application/json"}
224
231
  data, err = await _get_json(client, url, headers, timeout)
@@ -247,9 +254,75 @@ async def _usage_zai(
247
254
  return lines or ["no quota data"], None
248
255
 
249
256
 
257
+ def _zhipu_team_headers(entry: ApiKey) -> dict[str, str]:
258
+ return {
259
+ "Authorization": entry.key,
260
+ "Content-Type": "application/json",
261
+ "bigmodel-organization": entry.organization,
262
+ "bigmodel-project": entry.project,
263
+ }
264
+
265
+
266
+ async def _zhipu_team_plan(
267
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
268
+ ) -> str | None:
269
+ """Best-effort team subscription detail: plan name and expiry."""
270
+ url = f"{_origin(provider.base_url)}/api/biz/team/subscribe/product/querySubscribeDetail"
271
+ data, err = await _get_json(client, url, _zhipu_team_headers(entry), timeout)
272
+ if err or not data.get("success", False):
273
+ return None
274
+ detail = data.get("data") or {}
275
+ name = detail.get("productName")
276
+ if not name:
277
+ return None
278
+ line = f"plan: {name}"
279
+ end = detail.get("subscribeEndTime")
280
+ if end:
281
+ line += f" (until {end}"
282
+ if detail.get("autoRenew") == 0:
283
+ line += ", auto-renew off"
284
+ line += ")"
285
+ return line
286
+
287
+
288
+ async def _usage_zhipu_team(
289
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
290
+ ) -> tuple[list[str], str | None]:
291
+ url = f"{_origin(provider.base_url)}/api/monitor/usage/quota/limit?type=2"
292
+ data, err = await _get_json(client, url, _zhipu_team_headers(entry), timeout)
293
+ if err:
294
+ return [], err
295
+ if not data.get("success", False):
296
+ return [], f"platform error: {data.get('msg', 'unknown')}"
297
+ payload = data.get("data", {}) or {}
298
+ limits = payload.get("limits") or []
299
+ if not limits:
300
+ # type=2 answers success with empty limits when the key does not
301
+ # belong to the configured organization/project.
302
+ return [], "empty limits — key is not a member of the configured organization/project"
303
+ lines: list[str] = []
304
+ plan = await _zhipu_team_plan(client, provider, entry, timeout)
305
+ if plan:
306
+ lines.append(plan)
307
+ for limit in limits:
308
+ if not isinstance(limit, dict):
309
+ continue
310
+ # team plans are credit-metered; keep TOKENS_LIMIT handling anyway
311
+ if limit.get("type") not in ("CREDIT_LIMIT", "TOKENS_LIMIT"):
312
+ continue
313
+ # unit 3 = N-hour rolling window, unit 6 = weekly window
314
+ window = f"{limit.get('number', '?')}h" if limit.get("unit") == 3 else "weekly"
315
+ lines.append(
316
+ f"{window} window: used {limit.get('currentValue', '?')}/{limit.get('usage', '?')} "
317
+ f"credits ({limit.get('percentage', '?')}%), reset {_fmt_epoch_ms(limit.get('nextResetTime'))}"
318
+ )
319
+ return lines or ["no quota data"], None
320
+
321
+
250
322
  async def _usage_litellm(
251
- client: httpx.AsyncClient, provider: ProviderConfig, key: str, timeout: float
323
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
252
324
  ) -> tuple[list[str], str | None]:
325
+ key = entry.key
253
326
  url = f"{_origin(provider.base_url)}/key/info"
254
327
  headers = {"Authorization": f"Bearer {key}"}
255
328
  data, err = await _get_json(client, url, headers, timeout)
@@ -278,8 +351,9 @@ async def _usage_litellm(
278
351
 
279
352
 
280
353
  async def _usage_kimi(
281
- client: httpx.AsyncClient, provider: ProviderConfig, key: str, timeout: float
354
+ client: httpx.AsyncClient, provider: ProviderConfig, entry: ApiKey, timeout: float
282
355
  ) -> tuple[list[str], str | None]:
356
+ key = entry.key
283
357
  base = provider.base_url.rstrip("/")
284
358
  url = f"{base}/usages" if base.endswith("/v1") else f"{base}/v1/usages"
285
359
  headers = {"Authorization": f"Bearer {key}"}
@@ -323,18 +397,31 @@ _ADAPTERS = [
323
397
  ("minimax.io", _usage_minimax, "MiniMax coding_plan API"),
324
398
  ("api.kimi.com", _usage_kimi, "Kimi Code usages API (unofficial)"),
325
399
  ("z.ai", _usage_zai, "z.ai quota API (unofficial)"),
326
- ("bigmodel.cn", _usage_zai, "z.ai quota API (unofficial)"),
400
+ ("bigmodel.cn", _usage_zhipu_team, "zhipu team quota API (unofficial)"),
327
401
  ("llm.ustc.edu.cn", _usage_litellm, "LiteLLM /key/info"),
328
402
  ]
329
403
 
330
404
  # Platforms known to have NO key-based usage API — go straight to the probe.
331
405
  _PROBE_ONLY_MARKERS = ("xiaomimimo.com",)
332
406
 
407
+ # bigmodel.cn marker, reused to explain why a key got no quota adapter.
408
+ _ZHIPU_MARKER = "bigmodel.cn"
409
+
333
410
 
334
- def _find_adapter(base_url: str):
411
+ def _find_adapter(base_url: str, entry: ApiKey):
412
+ """First matching adapter for a base_url. Returns (adapter, label).
413
+
414
+ A bigmodel.cn (zhipu) team key whose entry lacks organization/project has
415
+ no quota endpoint to query — the personal coding-plan endpoint does not
416
+ apply to team keys — so it returns (None, None) and falls through to the
417
+ availability probe.
418
+ """
335
419
  for marker, adapter, label in _ADAPTERS:
336
- if marker in base_url:
337
- return adapter, label
420
+ if marker not in base_url:
421
+ continue
422
+ if adapter is _usage_zhipu_team and not (entry.organization and entry.project):
423
+ return None, None
424
+ return adapter, label
338
425
  return None, None
339
426
 
340
427
 
@@ -391,10 +478,10 @@ async def check_key_usage(
391
478
  if any(marker in provider.base_url for marker in _PROBE_ONLY_MARKERS):
392
479
  adapter, method = None, None
393
480
  else:
394
- adapter, method = _find_adapter(provider.base_url)
481
+ adapter, method = _find_adapter(provider.base_url, entry)
395
482
 
396
483
  if adapter is not None:
397
- lines, err = await adapter(client, provider, key, timeout)
484
+ lines, err = await adapter(client, provider, entry, timeout)
398
485
  if err is None:
399
486
  return UsageResult(provider.name, label, method, True, lines)
400
487
  logger.debug("usage adapter %s failed for %s: %s; falling back to probe", method, provider.name, err)
@@ -410,6 +497,10 @@ async def check_key_usage(
410
497
  lines, err = await _probe_chat(client, provider, key, timeout)
411
498
  method = f'minimal chat probe ("{_PROBE_INPUT}", max_tokens={_PROBE_MAX_TOKENS})'
412
499
  if err is None:
500
+ if _ZHIPU_MARKER in provider.base_url and not (entry.organization and entry.project):
501
+ lines = lines + [
502
+ "team quota not queried: set organization/project on this key entry"
503
+ ]
413
504
  return UsageResult(provider.name, label, method, True, lines)
414
505
  return UsageResult(provider.name, label, method, False, [err])
415
506
 
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
File without changes
File without changes
File without changes