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/settings.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ from datetime import datetime
9
+
10
+ from flexgate.config import (
11
+ ClaudeSettings,
12
+ GatewayConfig,
13
+ ProviderConfig,
14
+ RouteConfig,
15
+ load_config,
16
+ save_config,
17
+ )
18
+
19
+ CLAUDE_DIR = os.path.expanduser("~/.claude")
20
+ SETTINGS_GLOB = os.path.join(CLAUDE_DIR, "settings.json*")
21
+
22
+
23
+ def _scan_settings_files() -> list[tuple[str, str]]:
24
+ """Return [(filepath, suffix), ...] for all ~/.claude/settings.json* files."""
25
+ results = []
26
+ for path in sorted(glob.glob(SETTINGS_GLOB)):
27
+ basename = os.path.basename(path)
28
+ # settings.json → suffix "default", settings.json.zai → "zai"
29
+ if basename == "settings.json":
30
+ suffix = "default"
31
+ else:
32
+ suffix = basename.replace("settings.json.", "", 1)
33
+ # Skip backup files from our own apply command
34
+ if suffix.startswith("bak"):
35
+ continue
36
+ results.append((path, suffix))
37
+ return results
38
+
39
+
40
+ def _extract_provider(filepath: str) -> dict | None:
41
+ """Extract base_url and api_key from a settings.json file."""
42
+ try:
43
+ with open(filepath) as f:
44
+ data = json.load(f)
45
+ except (json.JSONDecodeError, OSError):
46
+ return None
47
+
48
+ env = data.get("env", {})
49
+ base_url = env.get("ANTHROPIC_BASE_URL", "")
50
+ api_key = env.get("ANTHROPIC_AUTH_TOKEN", "")
51
+
52
+ if not base_url or not api_key:
53
+ return None
54
+
55
+ return {"base_url": base_url.rstrip("/"), "api_key": api_key}
56
+
57
+
58
+ def settings_import(config_path: str) -> None:
59
+ """Read ~/.claude/settings.json* files and populate config.yaml providers."""
60
+ files = _scan_settings_files()
61
+ if not files:
62
+ print("No settings files found in ~/.claude/")
63
+ return
64
+
65
+ # Try to load existing config, or start fresh
66
+ try:
67
+ cfg = load_config(config_path)
68
+ except (FileNotFoundError, ValueError):
69
+ cfg = GatewayConfig()
70
+
71
+ print("Found settings files:")
72
+ for filepath, suffix in files:
73
+ provider = _extract_provider(filepath)
74
+ status = "OK" if provider else "SKIPPED (no credentials)"
75
+ print(f" {filepath} [{suffix}] — {status}")
76
+
77
+ print()
78
+
79
+ # Map suffixes to provider names
80
+ provider_map: dict[str, dict] = {}
81
+ for filepath, suffix in files:
82
+ provider = _extract_provider(filepath)
83
+ if not provider:
84
+ continue
85
+
86
+ if suffix == "default":
87
+ # Try to infer provider name from base_url
88
+ name = _infer_provider_name(provider["base_url"])
89
+ else:
90
+ name = suffix
91
+
92
+ provider_map[name] = provider
93
+
94
+ if not provider_map:
95
+ print("No valid provider credentials found in settings files.")
96
+ return
97
+
98
+ # Merge into config
99
+ for name, prov in provider_map.items():
100
+ if name in cfg.providers:
101
+ cfg.providers[name].base_url = prov["base_url"]
102
+ cfg.providers[name].api_key = prov["api_key"]
103
+ print(f" Updated provider: {name}")
104
+ else:
105
+ cfg.providers[name] = ProviderConfig(
106
+ name=name,
107
+ base_url=prov["base_url"],
108
+ api_key=prov["api_key"],
109
+ )
110
+ print(f" Added provider: {name}")
111
+
112
+ save_config(cfg, config_path)
113
+ print(f"\nConfig saved to {config_path}")
114
+
115
+
116
+ def _infer_provider_name(base_url: str) -> str:
117
+ """Guess provider name from the base_url."""
118
+ url_lower = base_url.lower()
119
+ if "z.ai" in url_lower:
120
+ return "zai"
121
+ if "minimax" in url_lower:
122
+ return "minimax"
123
+ if "openai" in url_lower:
124
+ return "openai"
125
+ if "anthropic" in url_lower:
126
+ return "anthropic"
127
+ # Fallback: use hostname
128
+ from urllib.parse import urlparse
129
+ parsed = urlparse(base_url)
130
+ return parsed.hostname or "unknown"
131
+
132
+
133
+ def _build_new_settings(cfg: GatewayConfig, existing: dict, auth_token: str) -> dict:
134
+ """Return the new settings dict: preserved non-env fields + gateway env block."""
135
+ new_settings: dict = {}
136
+
137
+ for key in ("permissions", "skipDangerousModePermissionPrompt", "skipAutoPermissionPrompt"):
138
+ if key in existing:
139
+ new_settings[key] = existing[key]
140
+
141
+ cs = cfg.claude_settings
142
+ new_settings["env"] = {
143
+ "ANTHROPIC_BASE_URL": f"http://{cfg.server.host}:{cfg.server.port}",
144
+ "ANTHROPIC_AUTH_TOKEN": auth_token,
145
+ "API_TIMEOUT_MS": str(cs.api_timeout_ms),
146
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": cs.default_opus_model,
147
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": cs.default_sonnet_model,
148
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": cs.default_haiku_model,
149
+ }
150
+ return new_settings
151
+
152
+
153
+ def settings_apply(config_path: str, *, auth_token: str | None = None) -> None:
154
+ """Apply config.yaml to ~/.claude/settings.json (with backup).
155
+
156
+ auth_token: value to write as ANTHROPIC_AUTH_TOKEN. If None, defaults to "gateway"
157
+ (preserves legacy behavior of this command for callers that do not pass a token).
158
+ """
159
+ cfg = load_config(config_path)
160
+ effective_token = "gateway" if auth_token is None else auth_token
161
+
162
+ settings_file = os.path.join(CLAUDE_DIR, "settings.json")
163
+ if not os.path.exists(settings_file):
164
+ print(f"Warning: {settings_file} does not exist, creating new file.")
165
+
166
+ # Backup existing settings.json
167
+ if os.path.exists(settings_file):
168
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
169
+ backup_path = os.path.join(CLAUDE_DIR, f"settings.json.bak.{timestamp}")
170
+ shutil.copy2(settings_file, backup_path)
171
+ print(f"Backup: {backup_path}")
172
+
173
+ # Read existing file (for field preservation)
174
+ existing: dict = {}
175
+ if os.path.exists(settings_file):
176
+ try:
177
+ with open(settings_file) as f:
178
+ existing = json.load(f)
179
+ except (json.JSONDecodeError, OSError):
180
+ pass
181
+
182
+ new_settings = _build_new_settings(cfg, existing, effective_token)
183
+
184
+ with open(settings_file, "w") as f:
185
+ json.dump(new_settings, f, indent=2)
186
+ f.write("\n")
187
+
188
+ cs = cfg.claude_settings
189
+ print(f"Applied: {settings_file}")
190
+ print(f" ANTHROPIC_BASE_URL = http://{cfg.server.host}:{cfg.server.port}")
191
+ print(f" OPUS_MODEL = {cs.default_opus_model}")
192
+ print(f" SONNET_MODEL = {cs.default_sonnet_model}")
193
+ print(f" HAIKU_MODEL = {cs.default_haiku_model}")
flexgate/sync.py ADDED
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+
8
+ from flexgate.config import ProviderConfig, load_config, save_config
9
+ from flexgate.registry import KNOWN_BASE_URLS
10
+
11
+ # Each provider is a folder under this path, named exactly after the provider,
12
+ # containing an API_KEY secret:
13
+ # /providers/minimax-tmy/API_KEY
14
+ PROVIDERS_FOLDER = "/providers"
15
+
16
+
17
+ def _match_base_url(name: str, table: dict[str, str]) -> str | None:
18
+ """Longest known prefix of `name` (dash boundary) → its base_url."""
19
+ for prefix in sorted(table, key=len, reverse=True):
20
+ if name == prefix or name.startswith(prefix + "-"):
21
+ return table[prefix]
22
+ return None
23
+
24
+
25
+ def _mask_key(key: str) -> str:
26
+ if len(key) <= 8:
27
+ return "***"
28
+ return key[:4] + "***" + key[-4:]
29
+
30
+
31
+ def _run_infisical(args: list[str]) -> subprocess.CompletedProcess[str]:
32
+ return subprocess.run(
33
+ ["infisical", *args],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=60,
37
+ )
38
+
39
+
40
+ def _check_prerequisites() -> None:
41
+ if shutil.which("infisical") is None:
42
+ print("infisical CLI not found in PATH.")
43
+ print("Install it first: https://infisical.com/docs/cli/overview")
44
+ sys.exit(1)
45
+
46
+ proc = _run_infisical(["login", "status"])
47
+ if proc.returncode != 0:
48
+ print("infisical CLI is not authenticated.")
49
+ print("Run 'infisical login' first.")
50
+ sys.exit(1)
51
+
52
+
53
+ def _parse_json(proc: subprocess.CompletedProcess[str], what: str) -> list:
54
+ try:
55
+ data = json.loads(proc.stdout)
56
+ except json.JSONDecodeError as e:
57
+ print(f"Could not parse infisical {what} output: {e}")
58
+ sys.exit(1)
59
+ return data if isinstance(data, list) else []
60
+
61
+
62
+ def _list_provider_folders(project_id: str, env: str) -> list[str]:
63
+ """List folder names under PROVIDERS_FOLDER; each name is a provider."""
64
+ proc = _run_infisical([
65
+ "secrets", "folders", "get",
66
+ "--path", PROVIDERS_FOLDER,
67
+ "-o", "json",
68
+ f"--projectId={project_id}",
69
+ f"--env={env}",
70
+ "--silent",
71
+ ])
72
+ if proc.returncode != 0:
73
+ print(f"Could not list folders under {PROVIDERS_FOLDER}:")
74
+ print((proc.stderr or proc.stdout).strip())
75
+ sys.exit(1)
76
+ return [str(f["folderName"]) for f in _parse_json(proc, "folders") if "folderName" in f]
77
+
78
+
79
+ def _fetch_folder_secrets(project_id: str, env: str, folder: str) -> dict[str, str]:
80
+ proc = _run_infisical([
81
+ "export",
82
+ "--format=json",
83
+ f"--env={env}",
84
+ f"--projectId={project_id}",
85
+ f"--path={PROVIDERS_FOLDER}/{folder}",
86
+ "--expand=false",
87
+ "--silent",
88
+ ])
89
+ if proc.returncode != 0:
90
+ print(f"Failed to fetch secrets from {PROVIDERS_FOLDER}/{folder}:")
91
+ print((proc.stderr or proc.stdout).strip())
92
+ sys.exit(1)
93
+
94
+ secrets: dict[str, str] = {}
95
+ for entry in _parse_json(proc, "export"):
96
+ if isinstance(entry, dict) and "key" in entry:
97
+ secrets[str(entry["key"])] = str(entry.get("value", ""))
98
+ return secrets
99
+
100
+
101
+ def sync_pull(config_path: str, dry_run: bool = False) -> None:
102
+ """Pull provider api_keys from Infisical into the flexgate config.
103
+
104
+ Provider mapping is derived from the Infisical layout: a folder named
105
+ after the provider under /providers, holding an API_KEY secret (an
106
+ optional MODELS secret lists comma-separated model names). Folders with
107
+ no matching provider in the config are auto-imported when their name
108
+ starts with a known prefix (see KNOWN_BASE_URLS in registry.py);
109
+ otherwise a warning is printed.
110
+ """
111
+ _check_prerequisites()
112
+
113
+ config = load_config(config_path)
114
+ inf = config.infisical
115
+ if not inf.project_id:
116
+ print("No Infisical project configured.")
117
+ print(f"Add an 'infisical' section to {config_path}:")
118
+ print()
119
+ print(" infisical:")
120
+ print(" project_id: \"<your-project-id>\"")
121
+ print(" env: \"dev\"")
122
+ sys.exit(1)
123
+
124
+ if not config.providers:
125
+ print("No providers configured; nothing to sync.")
126
+ return
127
+
128
+ print(f"Fetching secrets from Infisical (project {inf.project_id}, env {inf.env})...")
129
+ folders = _list_provider_folders(inf.project_id, inf.env)
130
+
131
+ updated: list[str] = []
132
+ unchanged: list[str] = []
133
+ missing: list[str] = []
134
+
135
+ for name, prov in config.providers.items():
136
+ if name not in folders:
137
+ missing.append(name)
138
+ continue
139
+ new_key = _fetch_folder_secrets(inf.project_id, inf.env, name).get("API_KEY", "")
140
+ if new_key and new_key != prov.api_key:
141
+ prov.api_key = new_key
142
+ updated.append(name)
143
+ else:
144
+ unchanged.append(name)
145
+
146
+ # Providers that exist in Infisical but not in the local config: try to
147
+ # auto-import them by matching the folder name against known prefixes.
148
+ base_url_table = dict(KNOWN_BASE_URLS)
149
+ base_url_table.update({n: p.base_url for n, p in config.providers.items()})
150
+
151
+ imported: list[str] = []
152
+ unknown: list[str] = []
153
+
154
+ for name in [f for f in folders if f not in config.providers]:
155
+ base_url = _match_base_url(name, base_url_table)
156
+ if base_url is None:
157
+ unknown.append(name)
158
+ continue
159
+ secrets = _fetch_folder_secrets(inf.project_id, inf.env, name)
160
+ api_key = secrets.get("API_KEY", "")
161
+ if not api_key:
162
+ unknown.append(name)
163
+ continue
164
+ models = [m.strip() for m in secrets.get("MODELS", "").split(",") if m.strip()]
165
+ config.providers[name] = ProviderConfig(
166
+ name=name,
167
+ base_url=base_url,
168
+ api_key=api_key,
169
+ available_models=models,
170
+ )
171
+ imported.append(name)
172
+
173
+ for name in updated:
174
+ print(f" {name:16s} updated (key: {_mask_key(config.providers[name].api_key)})")
175
+ for name in imported:
176
+ print(f" {name:16s} imported (new provider, base_url: {config.providers[name].base_url})")
177
+ for name in unchanged:
178
+ print(f" {name:16s} unchanged")
179
+ for name in missing:
180
+ print(f" {name:16s} no folder {PROVIDERS_FOLDER}/{name} in Infisical")
181
+ for name in unknown:
182
+ print(f" WARNING: {name} has no known prefix and was skipped.")
183
+ print(f" Add it to the config manually, or register its prefix in flexgate/registry.py.")
184
+
185
+ changed = updated or imported
186
+ if not changed:
187
+ print("\nAll keys are up to date.")
188
+ return
189
+
190
+ if dry_run:
191
+ n = len(updated) + len(imported)
192
+ print(f"\nDry run: {n} provider(s) would be updated/imported. No changes written.")
193
+ return
194
+
195
+ save_config(config, config_path)
196
+ print(f"\nUpdated {len(updated)} key(s), imported {len(imported)} provider(s). Saved: {config_path}")
197
+
198
+ # Hot-reload the authoritative systemd service, if it is active.
199
+ from flexgate.service import reload_service_if_active
200
+
201
+ msg = reload_service_if_active(config_path)
202
+ if msg:
203
+ print(msg)
flexgate/update.py ADDED
@@ -0,0 +1,188 @@
1
+ """Package self-update via pip/pipx/uv and config migration.
2
+
3
+ ``flexgate update`` handles both halves of an upgrade:
4
+
5
+ 1. package — detect how flexgate was installed (pipx / uv tool / pip) and
6
+ upgrade to the latest release published on PyPI;
7
+ 2. config — apply pending config.yaml schema migrations (with backup).
8
+
9
+ Use ``--check`` to only report what would change.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import urllib.request
20
+
21
+ from flexgate import __version__
22
+ from flexgate.migrate import CURRENT_CONFIG_VERSION, migrate_config_file, read_raw_config, detect_config_version, pending_steps
23
+
24
+ PYPI_JSON_URL = "https://pypi.org/pypi/flexgate/json"
25
+
26
+
27
+ def parse_version(text: str) -> tuple[int, ...]:
28
+ """Parse '1.2.3' into (1, 2, 3); pre-release suffixes are ignored."""
29
+ parts = re.findall(r"\d+", text)
30
+ return tuple(int(p) for p in parts) if parts else (0,)
31
+
32
+
33
+ def fetch_latest_version(timeout: float = 5.0) -> str | None:
34
+ """Latest released version on PyPI, or None when unreachable/offline."""
35
+ try:
36
+ req = urllib.request.Request(PYPI_JSON_URL, headers={"Accept": "application/json"})
37
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
38
+ data = json.loads(resp.read())
39
+ return str(data["info"]["version"])
40
+ except Exception:
41
+ return None
42
+
43
+
44
+ def detect_installer() -> tuple[str, list[str]] | None:
45
+ """Return (label, upgrade command) for the tool that installed flexgate."""
46
+ if shutil.which("pipx"):
47
+ out = subprocess.run(["pipx", "list", "--short"], capture_output=True, text=True).stdout
48
+ if any(line.split()[0] == "flexgate" for line in out.splitlines() if line.strip()):
49
+ return ("pipx", ["pipx", "upgrade", "flexgate"])
50
+ if shutil.which("uv"):
51
+ out = subprocess.run(["uv", "tool", "list"], capture_output=True, text=True).stdout
52
+ if any(line.split()[0] == "flexgate" for line in out.splitlines() if line.strip()):
53
+ return ("uv tool", ["uv", "tool", "upgrade", "flexgate"])
54
+ return ("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "flexgate"])
55
+
56
+
57
+ # ── cached background version check ───────────────────────────────
58
+ #
59
+ # Interactive commands (bare `flexgate`, `service status`) show a one-line
60
+ # upgrade hint when PyPI carries a newer release. The PyPI lookup is cached
61
+ # on disk so it hits the network at most once per UPDATE_CHECK_INTERVAL and
62
+ # never slows down or breaks the CLI (every failure is silent).
63
+
64
+ UPDATE_CHECK_INTERVAL = 24 * 3600 # seconds between PyPI lookups
65
+
66
+
67
+ def _update_check_cache_path() -> str:
68
+ import os
69
+
70
+ from flexgate.config import FLEXGATE_HOME
71
+ return os.path.join(FLEXGATE_HOME, "update-check.json")
72
+
73
+
74
+ def latest_version_cached(interval: float = UPDATE_CHECK_INTERVAL) -> str | None:
75
+ """Latest PyPI version, cached on disk; None on any failure."""
76
+ import os
77
+ import time
78
+
79
+ path = _update_check_cache_path()
80
+ try:
81
+ with open(path) as f:
82
+ cache = json.load(f)
83
+ if time.time() - float(cache.get("checked_at", 0)) < interval:
84
+ return cache.get("latest") or None
85
+ except Exception:
86
+ pass
87
+
88
+ latest = fetch_latest_version(timeout=1.5)
89
+ try:
90
+ tmp = path + ".tmp"
91
+ with open(tmp, "w") as f:
92
+ json.dump({"checked_at": time.time(), "latest": latest}, f)
93
+ os.replace(tmp, path)
94
+ except Exception:
95
+ pass
96
+ return latest
97
+
98
+
99
+ def update_notice() -> str | None:
100
+ """One-line upgrade hint when PyPI has a newer release, else None."""
101
+ try:
102
+ latest = latest_version_cached()
103
+ except Exception:
104
+ return None
105
+ if latest and parse_version(latest) > parse_version(__version__):
106
+ return (f"A newer flexgate {latest} is available "
107
+ f"(installed {__version__}) — run: flexgate update")
108
+ return None
109
+
110
+
111
+ def _config_status(config_path: str) -> tuple[int, list[int]] | None:
112
+ """(current_version, pending steps), or None if the config cannot be read."""
113
+ try:
114
+ data = read_raw_config(config_path)
115
+ except Exception:
116
+ return None
117
+ version = detect_config_version(data)
118
+ return version, pending_steps(version)
119
+
120
+
121
+ def run_update(config_path: str, *, check: bool = False, config_only: bool = False) -> int:
122
+ import os
123
+
124
+ print(f"flexgate {__version__} (config schema v{CURRENT_CONFIG_VERSION})")
125
+ failures = 0
126
+
127
+ # ── package update ────────────────────────────────────────────
128
+ latest = fetch_latest_version()
129
+ if latest is None:
130
+ print("\nPackage: could not reach PyPI — skipping package update check.")
131
+ else:
132
+ newer = parse_version(latest) > parse_version(__version__)
133
+ print(f"\nPackage: installed {__version__}, latest {latest}"
134
+ + (" — update available" if newer else " — up to date"))
135
+ if newer and not config_only:
136
+ installer = detect_installer()
137
+ if installer is None:
138
+ print(" Could not detect how flexgate was installed; upgrade manually.")
139
+ failures += 1
140
+ else:
141
+ label, cmd = installer
142
+ if check:
143
+ print(f" Would run: {' '.join(cmd)}")
144
+ else:
145
+ print(f" Upgrading via {label}: {' '.join(cmd)}")
146
+ proc = subprocess.run(cmd)
147
+ if proc.returncode != 0:
148
+ print(f" Package upgrade failed (exit {proc.returncode}).")
149
+ failures += 1
150
+ else:
151
+ print(f" Upgraded to {latest}. Restart the service to use it:")
152
+ print(" flexgate service restart")
153
+
154
+ # ── config migration ──────────────────────────────────────────
155
+ print()
156
+ if not os.path.exists(config_path):
157
+ print(f"Config: {config_path} not found — nothing to migrate.")
158
+ return 1 if failures else 0
159
+
160
+ status = _config_status(config_path)
161
+ if status is None:
162
+ print(f"Config: could not parse {config_path} — run 'flexgate doctor' for details.")
163
+ return 1
164
+
165
+ version, steps = status
166
+ if not steps:
167
+ print(f"Config: schema v{version} is current — nothing to migrate.")
168
+ else:
169
+ print(f"Config: schema v{version} → v{CURRENT_CONFIG_VERSION}, {len(steps)} migration(s) pending.")
170
+ if check:
171
+ for step in steps:
172
+ print(f" Would apply v{step} → v{step + 1}")
173
+ else:
174
+ result = migrate_config_file(config_path)
175
+ for line in result.applied:
176
+ print(f" Applied {line}")
177
+ if result.backup_path:
178
+ print(f" Backup: {result.backup_path}")
179
+ print(f" Migrated: {config_path}")
180
+
181
+ # ── reload the running service with the migrated config ──────
182
+ if not check:
183
+ from flexgate.service import reload_service_if_active
184
+ msg = reload_service_if_active(config_path)
185
+ if msg:
186
+ print(f"\n{msg}")
187
+
188
+ return 1 if failures else 0