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/config.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from flexgate.migrate import CURRENT_CONFIG_VERSION, detect_config_version
|
|
8
|
+
|
|
9
|
+
FLEXGATE_HOME = os.path.expanduser("~/.flexgate")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_default_config_path() -> str:
|
|
13
|
+
"""Config path priority: FLEXGATE_CONFIG env > ~/.flexgate/config.yaml."""
|
|
14
|
+
env = os.environ.get("FLEXGATE_CONFIG")
|
|
15
|
+
if env:
|
|
16
|
+
return env
|
|
17
|
+
return os.path.join(FLEXGATE_HOME, "config.yaml")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_home_dir() -> None:
|
|
21
|
+
os.makedirs(FLEXGATE_HOME, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProviderConfig:
|
|
26
|
+
name: str
|
|
27
|
+
base_url: str
|
|
28
|
+
api_key: str
|
|
29
|
+
available_models: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def default_model(self) -> str | None:
|
|
33
|
+
return self.available_models[0] if self.available_models else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RouteConfig:
|
|
38
|
+
pattern: re.Pattern[str]
|
|
39
|
+
provider_name: str
|
|
40
|
+
model: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ServerConfig:
|
|
45
|
+
host: str = "127.0.0.1"
|
|
46
|
+
port: int = 8765
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ClaudeSettings:
|
|
51
|
+
default_opus_model: str = "claude-opus-4-7"
|
|
52
|
+
default_sonnet_model: str = "claude-sonnet-4-6"
|
|
53
|
+
default_haiku_model: str = "claude-haiku-4-5"
|
|
54
|
+
api_timeout_ms: int = 3000000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class InfisicalConfig:
|
|
59
|
+
project_id: str = ""
|
|
60
|
+
env: str = "dev"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ScheduleEntry:
|
|
65
|
+
name: str
|
|
66
|
+
start_minutes: int
|
|
67
|
+
end_minutes: int
|
|
68
|
+
routes: list[RouteConfig] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class GatewayConfig:
|
|
73
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
74
|
+
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
75
|
+
routes: list[RouteConfig] = field(default_factory=list)
|
|
76
|
+
schedule: list[ScheduleEntry] = field(default_factory=list)
|
|
77
|
+
claude_settings: ClaudeSettings = field(default_factory=ClaudeSettings)
|
|
78
|
+
infisical: InfisicalConfig = field(default_factory=InfisicalConfig)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_hhmm(value: str) -> int:
|
|
82
|
+
"""Parse 'HH:MM' string to minutes since midnight."""
|
|
83
|
+
parts = value.strip().split(":")
|
|
84
|
+
if len(parts) != 2:
|
|
85
|
+
raise ValueError(f"Invalid time format '{value}', expected 'HH:MM'")
|
|
86
|
+
h, m = int(parts[0]), int(parts[1])
|
|
87
|
+
if not (0 <= h <= 24 and 0 <= m <= 59):
|
|
88
|
+
raise ValueError(f"Time out of range: '{value}'")
|
|
89
|
+
return h * 60 + m
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_routes(raw_routes: list[dict], providers: dict[str, ProviderConfig]) -> list[RouteConfig]:
|
|
93
|
+
routes: list[RouteConfig] = []
|
|
94
|
+
for r in raw_routes:
|
|
95
|
+
prov_name = r["provider"]
|
|
96
|
+
if prov_name not in providers:
|
|
97
|
+
raise ValueError(f"Route references unknown provider '{prov_name}'")
|
|
98
|
+
model = r.get("model")
|
|
99
|
+
if model is None and not providers[prov_name].available_models:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Route '{r['pattern']}' omits 'model' but provider '{prov_name}' "
|
|
102
|
+
f"has no 'available_models' to fall back to. "
|
|
103
|
+
f"Add 'available_models' to provider '{prov_name}' or set an explicit 'model' on the route."
|
|
104
|
+
)
|
|
105
|
+
routes.append(RouteConfig(
|
|
106
|
+
pattern=re.compile(r["pattern"]),
|
|
107
|
+
provider_name=prov_name,
|
|
108
|
+
model=model,
|
|
109
|
+
))
|
|
110
|
+
return routes
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_config(path: str | None = None) -> GatewayConfig:
|
|
114
|
+
import yaml
|
|
115
|
+
|
|
116
|
+
if path is None:
|
|
117
|
+
path = get_default_config_path()
|
|
118
|
+
|
|
119
|
+
with open(path) as f:
|
|
120
|
+
raw = yaml.safe_load(f)
|
|
121
|
+
|
|
122
|
+
if not isinstance(raw, dict):
|
|
123
|
+
raise ValueError(f"Config file {path} must be a YAML mapping")
|
|
124
|
+
|
|
125
|
+
config_version = detect_config_version(raw)
|
|
126
|
+
if config_version > CURRENT_CONFIG_VERSION:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{path} was written by a newer flexgate (config_version {config_version} > "
|
|
129
|
+
f"{CURRENT_CONFIG_VERSION}). Run 'flexgate update' to upgrade."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
cfg = GatewayConfig()
|
|
133
|
+
|
|
134
|
+
srv = raw.get("server", {})
|
|
135
|
+
host = srv.get("host", "127.0.0.1")
|
|
136
|
+
port = srv.get("port", 8765)
|
|
137
|
+
if not isinstance(host, str) or not host:
|
|
138
|
+
raise ValueError("server.host must be a non-empty string")
|
|
139
|
+
if isinstance(port, bool) or not isinstance(port, int) or not (1 <= port <= 65535):
|
|
140
|
+
raise ValueError("server.port must be an integer between 1 and 65535")
|
|
141
|
+
cfg.server = ServerConfig(
|
|
142
|
+
host=host,
|
|
143
|
+
port=port,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for name, prov in raw.get("providers", {}).items():
|
|
147
|
+
available = prov.get("available_models", []) or []
|
|
148
|
+
if not isinstance(available, list):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Provider '{name}': 'available_models' must be a list of model names"
|
|
151
|
+
)
|
|
152
|
+
cfg.providers[name] = ProviderConfig(
|
|
153
|
+
name=name,
|
|
154
|
+
base_url=prov["base_url"].rstrip("/"),
|
|
155
|
+
api_key=prov["api_key"],
|
|
156
|
+
available_models=[str(m) for m in available],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
cfg.routes = _parse_routes(raw.get("routes", []), cfg.providers)
|
|
160
|
+
|
|
161
|
+
for entry in raw.get("schedule", []):
|
|
162
|
+
cfg.schedule.append(ScheduleEntry(
|
|
163
|
+
name=entry.get("name", ""),
|
|
164
|
+
start_minutes=_parse_hhmm(entry["start"]),
|
|
165
|
+
end_minutes=_parse_hhmm(entry["end"]),
|
|
166
|
+
routes=_parse_routes(entry.get("routes", []), cfg.providers),
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
cs = raw.get("claude_settings", {})
|
|
170
|
+
if cs:
|
|
171
|
+
cfg.claude_settings = ClaudeSettings(
|
|
172
|
+
default_opus_model=cs.get("default_opus_model", cfg.claude_settings.default_opus_model),
|
|
173
|
+
default_sonnet_model=cs.get("default_sonnet_model", cfg.claude_settings.default_sonnet_model),
|
|
174
|
+
default_haiku_model=cs.get("default_haiku_model", cfg.claude_settings.default_haiku_model),
|
|
175
|
+
api_timeout_ms=cs.get("api_timeout_ms", cfg.claude_settings.api_timeout_ms),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
inf = raw.get("infisical", {})
|
|
179
|
+
if inf:
|
|
180
|
+
cfg.infisical = InfisicalConfig(
|
|
181
|
+
project_id=str(inf.get("project_id", "")),
|
|
182
|
+
env=str(inf.get("env", "dev")),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return cfg
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _serialize_routes(routes: list[RouteConfig]) -> list[dict]:
|
|
189
|
+
result: list[dict] = []
|
|
190
|
+
for route in routes:
|
|
191
|
+
r: dict = {"pattern": route.pattern.pattern, "provider": route.provider_name}
|
|
192
|
+
if route.model:
|
|
193
|
+
r["model"] = route.model
|
|
194
|
+
result.append(r)
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _format_hhmm(minutes: int) -> str:
|
|
199
|
+
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def save_config(cfg: GatewayConfig, path: str | None = None) -> None:
|
|
203
|
+
import yaml
|
|
204
|
+
|
|
205
|
+
if path is None:
|
|
206
|
+
path = get_default_config_path()
|
|
207
|
+
|
|
208
|
+
data: dict = {
|
|
209
|
+
"config_version": CURRENT_CONFIG_VERSION,
|
|
210
|
+
"server": {
|
|
211
|
+
"host": cfg.server.host,
|
|
212
|
+
"port": cfg.server.port,
|
|
213
|
+
},
|
|
214
|
+
"providers": {},
|
|
215
|
+
"claude_settings": {
|
|
216
|
+
"default_opus_model": cfg.claude_settings.default_opus_model,
|
|
217
|
+
"default_sonnet_model": cfg.claude_settings.default_sonnet_model,
|
|
218
|
+
"default_haiku_model": cfg.claude_settings.default_haiku_model,
|
|
219
|
+
"api_timeout_ms": cfg.claude_settings.api_timeout_ms,
|
|
220
|
+
},
|
|
221
|
+
"routes": _serialize_routes(cfg.routes),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if cfg.infisical.project_id:
|
|
225
|
+
data["infisical"] = {
|
|
226
|
+
"project_id": cfg.infisical.project_id,
|
|
227
|
+
"env": cfg.infisical.env,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for name, prov in cfg.providers.items():
|
|
231
|
+
entry: dict = {
|
|
232
|
+
"base_url": prov.base_url,
|
|
233
|
+
"api_key": prov.api_key,
|
|
234
|
+
}
|
|
235
|
+
if prov.available_models:
|
|
236
|
+
entry["available_models"] = list(prov.available_models)
|
|
237
|
+
data["providers"][name] = entry
|
|
238
|
+
|
|
239
|
+
if cfg.schedule:
|
|
240
|
+
schedule_data: list[dict] = []
|
|
241
|
+
for entry in cfg.schedule:
|
|
242
|
+
schedule_data.append({
|
|
243
|
+
"name": entry.name,
|
|
244
|
+
"start": _format_hhmm(entry.start_minutes),
|
|
245
|
+
"end": _format_hhmm(entry.end_minutes),
|
|
246
|
+
"routes": _serialize_routes(entry.routes),
|
|
247
|
+
})
|
|
248
|
+
data["schedule"] = schedule_data
|
|
249
|
+
|
|
250
|
+
with open(path, "w") as f:
|
|
251
|
+
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
TIER_PATTERNS: dict[str, str] = {
|
|
255
|
+
"opus": "^claude-opus",
|
|
256
|
+
"sonnet": "^claude-sonnet",
|
|
257
|
+
"haiku": "^claude-haiku",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
DEFAULT_CONFIG_TEMPLATE = """\
|
|
261
|
+
server:
|
|
262
|
+
host: "127.0.0.1"
|
|
263
|
+
port: 8765
|
|
264
|
+
|
|
265
|
+
# Each provider must declare `available_models`. The first entry is used as the
|
|
266
|
+
# fallback model whenever a route below omits its own `model:` field.
|
|
267
|
+
providers:
|
|
268
|
+
zai:
|
|
269
|
+
base_url: "https://api.z.ai/api/anthropic"
|
|
270
|
+
api_key: "your-zai-api-key"
|
|
271
|
+
available_models:
|
|
272
|
+
- "glm-5.1" # used as fallback when a route omits `model`
|
|
273
|
+
- "glm-4.6"
|
|
274
|
+
minimax:
|
|
275
|
+
base_url: "https://api.minimaxi.com/anthropic"
|
|
276
|
+
api_key: "your-minimax-api-key"
|
|
277
|
+
available_models:
|
|
278
|
+
- "MiniMax-M3"
|
|
279
|
+
|
|
280
|
+
claude_settings:
|
|
281
|
+
default_opus_model: "claude-opus-4-7"
|
|
282
|
+
default_sonnet_model: "claude-sonnet-4-6"
|
|
283
|
+
default_haiku_model: "claude-haiku-4-5"
|
|
284
|
+
api_timeout_ms: 3000000
|
|
285
|
+
|
|
286
|
+
# Infisical secret sync (optional): enables `flexgate sync` to pull provider
|
|
287
|
+
# api_keys from Infisical. Each provider is a folder under /providers named
|
|
288
|
+
# exactly after the provider, holding an API_KEY secret
|
|
289
|
+
# (e.g. /providers/minimax-tmy/API_KEY). An optional MODELS secret holds
|
|
290
|
+
# comma-separated model names. New folders are auto-imported by matching
|
|
291
|
+
# their name prefix against known base_urls (see KNOWN_BASE_URLS in
|
|
292
|
+
# flexgate/registry.py); unrecognized prefixes produce a warning.
|
|
293
|
+
# infisical:
|
|
294
|
+
# project_id: "your-infisical-project-id"
|
|
295
|
+
# env: "dev"
|
|
296
|
+
|
|
297
|
+
# Routes are matched top-to-bottom; first match wins.
|
|
298
|
+
# `model:` is optional — when omitted, the provider's first available_models
|
|
299
|
+
# entry is used. A provider referenced without `model` MUST have available_models.
|
|
300
|
+
routes:
|
|
301
|
+
- pattern: "^claude-opus"
|
|
302
|
+
provider: zai
|
|
303
|
+
model: "glm-5.1"
|
|
304
|
+
- pattern: "^claude-sonnet"
|
|
305
|
+
provider: minimax
|
|
306
|
+
model: "MiniMax-M3"
|
|
307
|
+
- pattern: "^claude-haiku"
|
|
308
|
+
provider: minimax
|
|
309
|
+
model: "MiniMax-M3"
|
|
310
|
+
- pattern: ".*" # catch-all, falls back to minimax's first available model
|
|
311
|
+
provider: minimax
|
|
312
|
+
"""
|
flexgate/doctor.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""``flexgate doctor`` — diagnose common installation and config problems.
|
|
2
|
+
|
|
3
|
+
Runs a series of read-only checks and prints OK / WARN / FAIL per item.
|
|
4
|
+
Exit code is 1 when any check FAILs, so it can gate a release or CI job.
|
|
5
|
+
It never modifies anything; fixes are applied by ``flexgate update``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from flexgate import __version__
|
|
17
|
+
from flexgate.config import load_config
|
|
18
|
+
from flexgate.migrate import (
|
|
19
|
+
CURRENT_CONFIG_VERSION,
|
|
20
|
+
detect_config_version,
|
|
21
|
+
pending_steps,
|
|
22
|
+
read_raw_config,
|
|
23
|
+
)
|
|
24
|
+
from flexgate.update import fetch_latest_version
|
|
25
|
+
|
|
26
|
+
OK, WARN, FAIL, SKIP = "OK", "WARN", "FAIL", "SKIP"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Finding:
|
|
31
|
+
status: str
|
|
32
|
+
label: str
|
|
33
|
+
detail: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_python(findings: list[Finding]) -> None:
|
|
37
|
+
v = sys.version_info
|
|
38
|
+
if v >= (3, 11):
|
|
39
|
+
findings.append(Finding(OK, "Python", f"{v.major}.{v.minor}.{v.micro}"))
|
|
40
|
+
else:
|
|
41
|
+
findings.append(Finding(FAIL, "Python", f"{v.major}.{v.minor}.{v.micro} — flexgate requires >= 3.11"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_package(findings: list[Finding], offline: bool) -> None:
|
|
45
|
+
if offline:
|
|
46
|
+
findings.append(Finding(SKIP, "Package update", "skipped (--offline)"))
|
|
47
|
+
return
|
|
48
|
+
latest = fetch_latest_version()
|
|
49
|
+
if latest is None:
|
|
50
|
+
findings.append(Finding(SKIP, "Package update", "PyPI unreachable"))
|
|
51
|
+
elif latest == __version__:
|
|
52
|
+
findings.append(Finding(OK, "Package update", f"{__version__} is the latest release"))
|
|
53
|
+
else:
|
|
54
|
+
findings.append(Finding(WARN, "Package update",
|
|
55
|
+
f"{__version__} → {latest} available — run 'flexgate update'"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_validated_config(config_path: str, findings: list[Finding]):
|
|
59
|
+
"""Add findings for config existence/parse/schema. Returns GatewayConfig or None."""
|
|
60
|
+
if not os.path.exists(config_path):
|
|
61
|
+
findings.append(Finding(FAIL, "Config file",
|
|
62
|
+
f"{config_path} not found — run 'flexgate config init'"))
|
|
63
|
+
return None
|
|
64
|
+
findings.append(Finding(OK, "Config file", config_path))
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
raw = read_raw_config(config_path)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
findings.append(Finding(FAIL, "Config YAML", f"cannot parse: {e}"))
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
version = detect_config_version(raw)
|
|
74
|
+
except ValueError as e:
|
|
75
|
+
findings.append(Finding(FAIL, "Config schema", str(e)))
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if version > CURRENT_CONFIG_VERSION:
|
|
79
|
+
findings.append(Finding(FAIL, "Config schema",
|
|
80
|
+
f"v{version} is newer than this flexgate (v{CURRENT_CONFIG_VERSION}) "
|
|
81
|
+
f"— upgrade flexgate first: flexgate update"))
|
|
82
|
+
return None
|
|
83
|
+
steps = pending_steps(version)
|
|
84
|
+
if steps:
|
|
85
|
+
findings.append(Finding(WARN, "Config schema",
|
|
86
|
+
f"v{version} is outdated — run 'flexgate update' "
|
|
87
|
+
f"to migrate to v{CURRENT_CONFIG_VERSION}"))
|
|
88
|
+
else:
|
|
89
|
+
findings.append(Finding(OK, "Config schema", f"v{version} (current)"))
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
config = load_config(config_path)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
findings.append(Finding(FAIL, "Config semantics", str(e)))
|
|
95
|
+
return None
|
|
96
|
+
findings.append(Finding(OK, "Config semantics", "providers/routes validate"))
|
|
97
|
+
return config
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_providers(config, findings: list[Finding]) -> None:
|
|
101
|
+
from flexgate.healthcheck import _is_placeholder_key
|
|
102
|
+
|
|
103
|
+
if not config.providers:
|
|
104
|
+
findings.append(Finding(FAIL, "Providers", "none configured"))
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
placeholders = [name for name, p in config.providers.items() if _is_placeholder_key(p.api_key)]
|
|
108
|
+
if placeholders:
|
|
109
|
+
findings.append(Finding(WARN, "Provider keys",
|
|
110
|
+
f"placeholder api_key in: {', '.join(placeholders)}"))
|
|
111
|
+
else:
|
|
112
|
+
findings.append(Finding(OK, "Provider keys", f"{len(config.providers)} provider(s) configured"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_routes(config, findings: list[Finding]) -> None:
|
|
116
|
+
if not config.routes:
|
|
117
|
+
findings.append(Finding(WARN, "Routes", "no routes — unmatched models will 404"))
|
|
118
|
+
return
|
|
119
|
+
catch_all_idx = [i for i, r in enumerate(config.routes) if r.pattern.pattern == ".*"]
|
|
120
|
+
if catch_all_idx and catch_all_idx[0] < len(config.routes) - 1:
|
|
121
|
+
shadowed = [r.pattern.pattern for r in config.routes[catch_all_idx[0] + 1:]]
|
|
122
|
+
findings.append(Finding(WARN, "Routes",
|
|
123
|
+
f"catch-all '.*' is not last; unreachable: {', '.join(shadowed)}"))
|
|
124
|
+
elif not catch_all_idx:
|
|
125
|
+
findings.append(Finding(WARN, "Routes", "no catch-all '.*' route — unmatched models will 404"))
|
|
126
|
+
else:
|
|
127
|
+
findings.append(Finding(OK, "Routes", f"{len(config.routes)} route(s), catch-all last"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_port(config, findings: list[Finding]) -> None:
|
|
131
|
+
host, port = config.server.host, config.server.port
|
|
132
|
+
with socket.socket() as sock:
|
|
133
|
+
sock.settimeout(1.0)
|
|
134
|
+
occupied = sock.connect_ex((host, port)) == 0
|
|
135
|
+
|
|
136
|
+
from flexgate.service import service_active, service_installed
|
|
137
|
+
|
|
138
|
+
if occupied:
|
|
139
|
+
if service_active():
|
|
140
|
+
findings.append(Finding(OK, "Port", f"{host}:{port} serving (service active)"))
|
|
141
|
+
else:
|
|
142
|
+
findings.append(Finding(WARN, "Port",
|
|
143
|
+
f"{host}:{port} is in use but the flexgate service is not active "
|
|
144
|
+
f"— another process may hold it"))
|
|
145
|
+
else:
|
|
146
|
+
if service_installed() and not service_active():
|
|
147
|
+
findings.append(Finding(WARN, "Port",
|
|
148
|
+
f"service installed but not running — 'flexgate service start'"))
|
|
149
|
+
else:
|
|
150
|
+
findings.append(Finding(OK, "Port", f"{host}:{port} free"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_systemd(findings: list[Finding]) -> None:
|
|
154
|
+
from flexgate.service import (
|
|
155
|
+
UNIT_MARKER,
|
|
156
|
+
_systemd_user_available,
|
|
157
|
+
_unit_path,
|
|
158
|
+
service_active,
|
|
159
|
+
service_installed,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
available, reason = _systemd_user_available()
|
|
163
|
+
if not available:
|
|
164
|
+
findings.append(Finding(WARN, "systemd", reason))
|
|
165
|
+
return
|
|
166
|
+
if not service_installed():
|
|
167
|
+
findings.append(Finding(WARN, "systemd service",
|
|
168
|
+
"not installed — 'flexgate service install'"))
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
with open(_unit_path()) as f:
|
|
172
|
+
content = f.read()
|
|
173
|
+
if UNIT_MARKER not in content:
|
|
174
|
+
findings.append(Finding(WARN, "systemd service",
|
|
175
|
+
"unit predates the current format — reinstall with "
|
|
176
|
+
"'flexgate service install'"))
|
|
177
|
+
return
|
|
178
|
+
except OSError as e:
|
|
179
|
+
findings.append(Finding(FAIL, "systemd service", f"cannot read unit file: {e}"))
|
|
180
|
+
return
|
|
181
|
+
if service_active():
|
|
182
|
+
findings.append(Finding(OK, "systemd service", "installed and active"))
|
|
183
|
+
else:
|
|
184
|
+
findings.append(Finding(WARN, "systemd service",
|
|
185
|
+
"installed but not active — 'flexgate service start'"))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check_claude_settings(config, findings: list[Finding]) -> None:
|
|
189
|
+
settings_path = os.path.expanduser("~/.claude/settings.json")
|
|
190
|
+
if not os.path.exists(settings_path):
|
|
191
|
+
findings.append(Finding(SKIP, "Claude settings", "~/.claude/settings.json not found"))
|
|
192
|
+
return
|
|
193
|
+
try:
|
|
194
|
+
with open(settings_path) as f:
|
|
195
|
+
settings = json.load(f)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
findings.append(Finding(WARN, "Claude settings", f"cannot parse: {e}"))
|
|
198
|
+
return
|
|
199
|
+
base_url = (settings.get("env") or {}).get("ANTHROPIC_BASE_URL", "")
|
|
200
|
+
expected = f"http://{config.server.host}:{config.server.port}"
|
|
201
|
+
if base_url.rstrip("/") == expected:
|
|
202
|
+
findings.append(Finding(OK, "Claude settings", f"ANTHROPIC_BASE_URL → {expected}"))
|
|
203
|
+
else:
|
|
204
|
+
findings.append(Finding(WARN, "Claude settings",
|
|
205
|
+
f"ANTHROPIC_BASE_URL is {base_url!r}, expected {expected!r} "
|
|
206
|
+
f"— 'flexgate settings apply'"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def run_doctor(config_path: str, *, offline: bool = False) -> int:
|
|
210
|
+
print(f"flexgate doctor — version {__version__}, config schema v{CURRENT_CONFIG_VERSION}\n")
|
|
211
|
+
|
|
212
|
+
findings: list[Finding] = []
|
|
213
|
+
_check_python(findings)
|
|
214
|
+
_check_package(findings, offline)
|
|
215
|
+
|
|
216
|
+
config = _load_validated_config(config_path, findings)
|
|
217
|
+
if config is not None:
|
|
218
|
+
_check_providers(config, findings)
|
|
219
|
+
_check_routes(config, findings)
|
|
220
|
+
_check_port(config, findings)
|
|
221
|
+
_check_claude_settings(config, findings)
|
|
222
|
+
_check_systemd(findings)
|
|
223
|
+
|
|
224
|
+
width = max(len(f.label) for f in findings)
|
|
225
|
+
for f in findings:
|
|
226
|
+
line = f"[{f.status:4s}] {f.label:{width}s}"
|
|
227
|
+
if f.detail:
|
|
228
|
+
line += f" {f.detail}"
|
|
229
|
+
print(line)
|
|
230
|
+
|
|
231
|
+
fails = sum(1 for f in findings if f.status == FAIL)
|
|
232
|
+
warns = sum(1 for f in findings if f.status == WARN)
|
|
233
|
+
print()
|
|
234
|
+
if fails:
|
|
235
|
+
print(f"{fails} problem(s) must be fixed; {warns} warning(s).")
|
|
236
|
+
return 1
|
|
237
|
+
if warns:
|
|
238
|
+
print(f"No blocking problems; {warns} warning(s). 'flexgate update' fixes outdated versions.")
|
|
239
|
+
else:
|
|
240
|
+
print("Everything looks healthy.")
|
|
241
|
+
return 0
|