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/__init__.py +3 -0
- flexgate/__main__.py +3 -0
- flexgate/cli.py +935 -0
- flexgate/config.py +312 -0
- flexgate/doctor.py +241 -0
- flexgate/guardian.py +252 -0
- flexgate/healthcheck.py +197 -0
- flexgate/main.py +56 -0
- flexgate/migrate.py +138 -0
- flexgate/proxy.py +165 -0
- flexgate/registry.py +22 -0
- flexgate/router.py +75 -0
- flexgate/server.py +85 -0
- flexgate/service.py +1205 -0
- flexgate/settings.py +193 -0
- flexgate/sync.py +203 -0
- flexgate/update.py +188 -0
- flexgate-0.1.0.dist-info/METADATA +366 -0
- flexgate-0.1.0.dist-info/RECORD +21 -0
- flexgate-0.1.0.dist-info/WHEEL +4 -0
- flexgate-0.1.0.dist-info/entry_points.txt +2 -0
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)
|