flexgate 0.6.0__tar.gz → 0.7.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 (40) hide show
  1. {flexgate-0.6.0 → flexgate-0.7.0}/CLAUDE.md +7 -2
  2. {flexgate-0.6.0 → flexgate-0.7.0}/PKG-INFO +1 -1
  3. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/__init__.py +1 -1
  4. flexgate-0.7.0/flexgate/fallback.py +99 -0
  5. flexgate-0.7.0/flexgate/images.py +192 -0
  6. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/server.py +13 -1
  7. {flexgate-0.6.0 → flexgate-0.7.0}/.github/workflows/docs.yml +0 -0
  8. {flexgate-0.6.0 → flexgate-0.7.0}/.github/workflows/release.yml +0 -0
  9. {flexgate-0.6.0 → flexgate-0.7.0}/.gitignore +0 -0
  10. {flexgate-0.6.0 → flexgate-0.7.0}/README.md +0 -0
  11. {flexgate-0.6.0 → flexgate-0.7.0}/config.yaml.template +0 -0
  12. {flexgate-0.6.0 → flexgate-0.7.0}/docs/Makefile +0 -0
  13. {flexgate-0.6.0 → flexgate-0.7.0}/docs/_static/.gitkeep +0 -0
  14. {flexgate-0.6.0 → flexgate-0.7.0}/docs/cli.md +0 -0
  15. {flexgate-0.6.0 → flexgate-0.7.0}/docs/conf.py +0 -0
  16. {flexgate-0.6.0 → flexgate-0.7.0}/docs/configuration.md +0 -0
  17. {flexgate-0.6.0 → flexgate-0.7.0}/docs/development.md +0 -0
  18. {flexgate-0.6.0 → flexgate-0.7.0}/docs/index.md +0 -0
  19. {flexgate-0.6.0 → flexgate-0.7.0}/docs/quickstart.md +0 -0
  20. {flexgate-0.6.0 → flexgate-0.7.0}/docs/requirements.txt +0 -0
  21. {flexgate-0.6.0 → flexgate-0.7.0}/docs/usage.md +0 -0
  22. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/__main__.py +0 -0
  23. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/cli.py +0 -0
  24. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/config.py +0 -0
  25. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/doctor.py +0 -0
  26. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/healthcheck.py +0 -0
  27. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/main.py +0 -0
  28. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/migrate.py +0 -0
  29. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/proxy.py +0 -0
  30. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/router.py +0 -0
  31. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/service.py +0 -0
  32. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/settings.py +0 -0
  33. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/sync.py +0 -0
  34. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/ui.py +0 -0
  35. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/update.py +0 -0
  36. {flexgate-0.6.0 → flexgate-0.7.0}/flexgate/usage.py +0 -0
  37. {flexgate-0.6.0 → flexgate-0.7.0}/pyproject.toml +0 -0
  38. {flexgate-0.6.0 → flexgate-0.7.0}/test.png +0 -0
  39. {flexgate-0.6.0 → flexgate-0.7.0}/test_multimodal.py +0 -0
  40. {flexgate-0.6.0 → flexgate-0.7.0}/test_multimodal_gateway.py +0 -0
@@ -73,10 +73,13 @@ without a systemd user instance.
73
73
 
74
74
  ```
75
75
  Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
76
- → server.py (Starlette app, single route)
76
+ → server.py (Starlette app, /v1/messages + /v1/images/* routes)
77
77
  → router.py (resolve(): regex match model, check schedule windows first, then default routes)
78
78
  → proxy.py (rewrite model field if override, swap x-api-key, SSE streaming pass-through)
79
79
  → Upstream provider (z.ai, minimax, xiaomi, etc.)
80
+
81
+ OpenAI-style client → POST /v1/images/generations
82
+ → images.py (translate to MiniMax /v1/image_generation; provider auto-detected by base_url)
80
83
  ```
81
84
 
82
85
  ### Source files (`flexgate/`)
@@ -89,7 +92,9 @@ Claude Code → POST /v1/messages (model="claude-sonnet-4-6")
89
92
  | `router.py` | `resolve(config, model)` — schedule-first then default routes, first regex match wins; model aliases normalized before matching |
90
93
  | `proxy.py` | `handle_request()` — httpx async proxy, per-key fallback retry loop, SSE streaming + JSON pass-through |
91
94
  | `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 |
95
+ | `server.py` | Starlette app creation, `POST /v1/messages` + `POST /v1/images/{generations,edits}` endpoints, `SIGUSR1` lifespan reload |
96
+ | `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` |
97
+ | `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
98
  | `main.py` | Thin bootstrap: load config → create app → run uvicorn |
94
99
  | `service.py` | Authoritative systemd user-service install/start/stop/restart/reload/status and legacy PID migration |
95
100
  | `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) |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flexgate
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -1,3 +1,3 @@
1
1
  """Flexgate — Flexible API Gateway for Claude Code."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -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
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
File without changes
File without changes