flexgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flexgate/proxy.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+
6
+ import httpx
7
+ from starlette.responses import JSONResponse, StreamingResponse
8
+
9
+ from flexgate.config import ProviderConfig
10
+
11
+ logger = logging.getLogger("flexgate.proxy")
12
+
13
+ MULTIMODAL_MODELS = {"MiniMax-M3"}
14
+ _IMAGE_NOT_SUPPORTED_NOTE = "[flexgate] image message is not handled because multimodal is not supported by the current model"
15
+
16
+
17
+ def _model_supports_multimodal(model: str) -> bool:
18
+ return model in MULTIMODAL_MODELS
19
+
20
+
21
+ def _has_image_content(body_json: dict) -> bool:
22
+ for msg in body_json.get("messages", []):
23
+ content = msg.get("content")
24
+ if isinstance(content, list):
25
+ for block in content:
26
+ if isinstance(block, dict) and block.get("type") in ("image", "image_url"):
27
+ return True
28
+ return False
29
+
30
+
31
+ def _strip_images_from_request(body_json: dict) -> None:
32
+ for msg in body_json.get("messages", []):
33
+ content = msg.get("content")
34
+ if not isinstance(content, list):
35
+ continue
36
+ remaining = [b for b in content if not (isinstance(b, dict) and b.get("type") in ("image", "image_url"))]
37
+ if not remaining:
38
+ remaining = [{"type": "text", "text": _IMAGE_NOT_SUPPORTED_NOTE}]
39
+ else:
40
+ remaining.append({"type": "text", "text": _IMAGE_NOT_SUPPORTED_NOTE})
41
+ msg["content"] = remaining
42
+
43
+
44
+ def _add_note_to_response(data: dict) -> None:
45
+ for block in data.get("content", []):
46
+ if isinstance(block, dict) and block.get("type") == "text":
47
+ block["text"] += "\n" + _IMAGE_NOT_SUPPORTED_NOTE
48
+ return
49
+ data.setdefault("content", []).append({"type": "text", "text": _IMAGE_NOT_SUPPORTED_NOTE})
50
+
51
+
52
+ def _error_json(error_type: str, message: str, status: int) -> JSONResponse:
53
+ return JSONResponse(
54
+ {"type": "error", "error": {"type": error_type, "message": message}},
55
+ status_code=status,
56
+ )
57
+
58
+
59
+ def _build_upstream_headers(
60
+ incoming_headers: dict[str, str], provider: ProviderConfig
61
+ ) -> dict[str, str]:
62
+ return {
63
+ "content-type": "application/json",
64
+ "x-api-key": provider.api_key,
65
+ "anthropic-version": incoming_headers.get("anthropic-version", "2023-06-01"),
66
+ "anthropic-beta": incoming_headers.get("anthropic-beta", ""),
67
+ }
68
+
69
+
70
+ async def handle_request(
71
+ client: httpx.AsyncClient,
72
+ body: bytes,
73
+ body_json: dict,
74
+ incoming_headers: dict[str, str],
75
+ provider: ProviderConfig,
76
+ model_override: str | None,
77
+ ) -> JSONResponse | StreamingResponse:
78
+ if model_override:
79
+ body_json["model"] = model_override
80
+ body = json.dumps(body_json, ensure_ascii=False).encode()
81
+
82
+ url = f"{provider.base_url}/v1/messages"
83
+ headers = _build_upstream_headers(incoming_headers, provider)
84
+
85
+ is_stream = body_json.get("stream", False)
86
+ model = body_json.get("model", "")
87
+ strip_images = not _model_supports_multimodal(model) and _has_image_content(body_json)
88
+
89
+ if strip_images:
90
+ _strip_images_from_request(body_json)
91
+ body = json.dumps(body_json, ensure_ascii=False).encode()
92
+ logger.info("Stripped image content for non-multimodal model: %s", model)
93
+
94
+ if is_stream:
95
+ return await _stream_proxy(client, url, headers, body, provider.name, strip_images)
96
+ return await _regular_proxy(client, url, headers, body, provider.name, strip_images)
97
+
98
+
99
+ async def _regular_proxy(
100
+ client: httpx.AsyncClient,
101
+ url: str,
102
+ headers: dict[str, str],
103
+ body: bytes,
104
+ provider_name: str,
105
+ images_stripped: bool = False,
106
+ ) -> JSONResponse:
107
+ try:
108
+ resp = await client.post(url, headers=headers, content=body)
109
+ except httpx.ConnectError as exc:
110
+ logger.error("Provider %s unreachable: %s", provider_name, exc)
111
+ return _error_json("api_error", f"Provider unreachable: {provider_name}", 502)
112
+
113
+ try:
114
+ data = resp.json()
115
+ except Exception:
116
+ return _error_json("api_error", resp.text[:500], resp.status_code)
117
+
118
+ if images_stripped and resp.status_code == 200:
119
+ _add_note_to_response(data)
120
+
121
+ return JSONResponse(data, status_code=resp.status_code)
122
+
123
+
124
+ async def _stream_proxy(
125
+ client: httpx.AsyncClient,
126
+ url: str,
127
+ headers: dict[str, str],
128
+ body: bytes,
129
+ provider_name: str,
130
+ images_stripped: bool = False,
131
+ ) -> StreamingResponse:
132
+ try:
133
+ req = client.build_request("POST", url, headers=headers, content=body)
134
+ upstream = await client.send(req, stream=True)
135
+ except httpx.ConnectError as exc:
136
+ logger.error("Provider %s unreachable: %s", provider_name, exc)
137
+ return _error_json("api_error", f"Provider unreachable: {provider_name}", 502)
138
+
139
+ if upstream.status_code != 200:
140
+ error_body = await upstream.aread()
141
+ await upstream.aclose()
142
+ try:
143
+ data = json.loads(error_body)
144
+ except Exception:
145
+ data = {"type": "error", "error": {"type": "api_error", "message": error_body.decode(errors="replace")[:500]}}
146
+ return JSONResponse(data, status_code=upstream.status_code)
147
+
148
+ async def generate():
149
+ try:
150
+ async for chunk in upstream.aiter_bytes():
151
+ yield chunk
152
+ except httpx.ReadError:
153
+ logger.error("Upstream %s connection lost during streaming", provider_name)
154
+ finally:
155
+ await upstream.aclose()
156
+
157
+ return StreamingResponse(
158
+ generate(),
159
+ status_code=200,
160
+ headers={
161
+ "content-type": "text/event-stream",
162
+ "cache-control": "no-cache",
163
+ "connection": "keep-alive",
164
+ },
165
+ )
flexgate/registry.py ADDED
@@ -0,0 +1,22 @@
1
+ """Registry of known upstream LLM services.
2
+
3
+ `KNOWN_BASE_URLS` maps a service name prefix to its Anthropic-compatible
4
+ base_url. `flexgate sync` uses it to auto-import providers that exist in
5
+ Infisical but not yet in the config: the Infisical folder name is matched
6
+ against these prefixes on a dash boundary, longest prefix first
7
+ (e.g. 'minimax-tmy' -> minimax's base_url).
8
+
9
+ To onboard a new service, add one entry here — no other code changes needed.
10
+ Providers already present in the config are merged on top of this table at
11
+ sync time, so their (possibly newer) base_urls take precedence.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # prefix -> base_url
17
+ KNOWN_BASE_URLS: dict[str, str] = {
18
+ "minimax": "https://api.minimaxi.com/anthropic",
19
+ "zai": "https://api.z.ai/api/anthropic",
20
+ "ustc": "https://api.llm.ustc.edu.cn",
21
+ "kimi": "https://api.kimi.com/coding",
22
+ }
flexgate/router.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ from flexgate.config import GatewayConfig, ProviderConfig, RouteConfig
7
+
8
+ logger = logging.getLogger("flexgate")
9
+
10
+
11
+ class NoRouteMatchError(Exception):
12
+ def __init__(self, model: str) -> None:
13
+ self.model = model
14
+ super().__init__(f"No route matches model: {model}")
15
+
16
+
17
+ def _current_minutes() -> int:
18
+ now = datetime.now()
19
+ return now.hour * 60 + now.minute
20
+
21
+
22
+ def _in_window(minutes: int, start: int, end: int) -> bool:
23
+ if start == end:
24
+ return True
25
+ if start < end:
26
+ return start <= minutes < end
27
+ # Overnight wrap: e.g. 22:00-06:00
28
+ return minutes >= start or minutes < end
29
+
30
+
31
+ def _match_route(routes: list[RouteConfig], model: str) -> tuple[ProviderConfig, str | None] | None:
32
+ for route in routes:
33
+ if route.pattern.search(model):
34
+ return route.provider_name, route.model
35
+ return None
36
+
37
+
38
+ def resolve(config: GatewayConfig, model: str) -> tuple[ProviderConfig, str | None, str]:
39
+ """Return (provider, model_override, schedule_name) for the matching route.
40
+
41
+ model_override is always a concrete model name: if the matched route omits
42
+ 'model', it falls back to the provider's first available_models entry.
43
+ """
44
+ now = _current_minutes()
45
+
46
+ for entry in config.schedule:
47
+ if _in_window(now, entry.start_minutes, entry.end_minutes):
48
+ match = _match_route(entry.routes, model)
49
+ if match:
50
+ prov_name, model_override = match
51
+ provider = config.providers[prov_name]
52
+ model_override = _resolve_model(provider, model_override)
53
+ label = entry.name or f"{entry.start_minutes//60:02d}:{entry.start_minutes%60:02d}-{entry.end_minutes//60:02d}:{entry.end_minutes%60:02d}"
54
+ logger.debug("schedule [%s] matched for model %s", label, model)
55
+ return provider, model_override, label
56
+
57
+ match = _match_route(config.routes, model)
58
+ if match:
59
+ prov_name, model_override = match
60
+ provider = config.providers[prov_name]
61
+ model_override = _resolve_model(provider, model_override)
62
+ return provider, model_override, "default"
63
+
64
+ raise NoRouteMatchError(model)
65
+
66
+
67
+ def _resolve_model(provider: ProviderConfig, model_override: str | None) -> str:
68
+ """Ensure a concrete model: fall back to the provider's first available model."""
69
+ if model_override:
70
+ return model_override
71
+ if provider.available_models:
72
+ return provider.available_models[0]
73
+ raise ValueError(
74
+ f"Provider '{provider.name}' has no model override and no available_models to fall back to"
75
+ )
flexgate/server.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import signal
6
+ import time
7
+ from collections.abc import AsyncGenerator
8
+
9
+ import httpx
10
+ from starlette.applications import Starlette
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse
13
+ from starlette.routing import Route
14
+
15
+ from flexgate.config import GatewayConfig, load_config
16
+ from flexgate.proxy import handle_request
17
+ from flexgate.router import NoRouteMatchError, resolve
18
+
19
+ logger = logging.getLogger("flexgate")
20
+
21
+
22
+ def _error_json(error_type: str, message: str, status: int) -> JSONResponse:
23
+ return JSONResponse(
24
+ {"type": "error", "error": {"type": error_type, "message": message}},
25
+ status_code=status,
26
+ )
27
+
28
+
29
+ async def messages(request: Request) -> JSONResponse:
30
+ t0 = time.monotonic()
31
+ config: GatewayConfig = request.app.state.config
32
+ client: httpx.AsyncClient = request.app.state.client
33
+
34
+ body_bytes = await request.body()
35
+ try:
36
+ body_json = json.loads(body_bytes)
37
+ except json.JSONDecodeError:
38
+ return _error_json("invalid_request_error", "Invalid JSON in request body", 400)
39
+
40
+ model_name = body_json.get("model", "")
41
+
42
+ try:
43
+ provider, model_override, schedule_name = resolve(config, model_name)
44
+ except NoRouteMatchError as exc:
45
+ return _error_json("not_found_error", str(exc), 503)
46
+
47
+ display_model = model_override or model_name
48
+ logger.info("[%s] %s -> %s (%s)", schedule_name, model_name, provider.name, display_model)
49
+
50
+ incoming_headers = dict(request.headers)
51
+ resp = await handle_request(
52
+ client, body_bytes, body_json, incoming_headers, provider, model_override,
53
+ )
54
+
55
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
56
+ logger.info(
57
+ "[%s] %s -> %s (%s) | %s | %dms",
58
+ schedule_name, model_name, provider.name, display_model, resp.status_code, elapsed_ms,
59
+ )
60
+ return resp
61
+
62
+
63
+ def create_app(config: GatewayConfig, config_path: str | None = None) -> Starlette:
64
+ client = httpx.AsyncClient(
65
+ timeout=httpx.Timeout(connect=10, read=3600, write=10, pool=10),
66
+ )
67
+
68
+ routes = [Route("/v1/messages", messages, methods=["POST"])]
69
+
70
+ async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
71
+ app.state.config = config
72
+ app.state.client = client
73
+
74
+ def _handle_sigusr1(signum, frame):
75
+ try:
76
+ app.state.config = load_config(config_path)
77
+ logger.info("Config reloaded via SIGUSR1")
78
+ except Exception as e:
79
+ logger.error("Failed to reload config: %s", e)
80
+
81
+ signal.signal(signal.SIGUSR1, _handle_sigusr1)
82
+ yield
83
+ await client.aclose()
84
+
85
+ return Starlette(routes=routes, lifespan=lifespan)