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/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