llm-switcher 1.6.5 → 1.6.7

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.
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ """Read public upstream evidence; leave interpretation and alerts to Hermes. Stdlib only."""
3
+ import argparse
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from datetime import datetime, timedelta, timezone
6
+ import hashlib
7
+ from html.parser import HTMLParser
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import subprocess
13
+ import tempfile
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import xml.etree.ElementTree as ET
18
+
19
+ MAX_BYTES = 4 * 1024 * 1024
20
+ TOPICS = re.compile(r"compac|responses|codex|claude|gemini|tool|strict|schema|stream|sse|websocket|context|catalog|signature|thinking|proxy|oauth|retry|429|failover|gateway|provider|certificate|multimodal|image|resume", re.I)
21
+
22
+
23
+ def github_token():
24
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
25
+ if token:
26
+ return token
27
+ # Reuse the VPS's existing GitHub credential in memory, only for api.github.com.
28
+ # Verify the checkout origin first; never print helper output or persist the credential.
29
+ checkout = Path("/opt/zencore")
30
+ if not checkout.is_dir():
31
+ return None
32
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
33
+ try:
34
+ origin = subprocess.run(["git", "remote", "get-url", "origin"], cwd=checkout, env=env, capture_output=True, text=True, timeout=10, check=True).stdout.strip()
35
+ if origin != "https://github.com/louisphamdev/zencore.git":
36
+ return None
37
+ result = subprocess.run(["git", "credential", "fill"], cwd=checkout, env=env, capture_output=True, text=True, timeout=10,
38
+ input="protocol=https\nhost=github.com\npath=louisphamdev/zencore.git\n\n", check=True)
39
+ fields = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
40
+ return fields.get("password") or None
41
+ except (OSError, subprocess.SubprocessError):
42
+ return None
43
+
44
+
45
+ def atomic_json(path, value):
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as f:
48
+ json.dump(value, f, ensure_ascii=False, indent=2)
49
+ f.write("\n")
50
+ temporary = f.name
51
+ os.replace(temporary, path)
52
+
53
+
54
+ def read_json(path, default):
55
+ try:
56
+ return json.loads(path.read_text(encoding="utf-8"))
57
+ except FileNotFoundError:
58
+ return default
59
+
60
+
61
+ def fetch(url, token=None):
62
+ headers = {"User-Agent": "Hermes-gateway-upstream-watch/1", "Accept": "application/json" if url.startswith("https://api.github.com/") else "*/*"}
63
+ if token and url.startswith("https://api.github.com/"):
64
+ headers["Authorization"] = f"Bearer {token}"
65
+ req = urllib.request.Request(url, headers=headers)
66
+ with urllib.request.urlopen(req, timeout=20) as response:
67
+ data = response.read(MAX_BYTES + 1)
68
+ if len(data) > MAX_BYTES:
69
+ raise RuntimeError("source exceeds 4 MiB evidence limit")
70
+ return data, dict(response.headers)
71
+
72
+
73
+ def atom_entries(data):
74
+ ns = {"a": "http://www.w3.org/2005/Atom"}
75
+ root = ET.fromstring(data)
76
+ entries = []
77
+ for item in root.findall("a:entry", ns):
78
+ link = next((x.get("href") for x in item.findall("a:link", ns) if x.get("rel", "alternate") == "alternate"), None)
79
+ entries.append({"id": item.findtext("a:id", "", ns), "title": item.findtext("a:title", "", ns).strip(),
80
+ "updated_at": item.findtext("a:updated", "", ns), "url": link})
81
+ return entries
82
+
83
+
84
+ class PageText(HTMLParser):
85
+ def __init__(self):
86
+ super().__init__()
87
+ self.skip = 0
88
+ self.parts = []
89
+
90
+ def handle_starttag(self, tag, attrs):
91
+ if tag in ("script", "style"):
92
+ self.skip += 1
93
+
94
+ def handle_endtag(self, tag):
95
+ if tag in ("script", "style") and self.skip:
96
+ self.skip -= 1
97
+
98
+ def handle_data(self, data):
99
+ if not self.skip and data.strip():
100
+ self.parts.append(data.strip())
101
+
102
+
103
+ def scan_repo(repo, since, prior, token):
104
+ name = repo["repo"]
105
+ result = {"repo": name, "group": repo["group"], "since": since, "sources": {}, "errors": [], "issues": [], "commits": [], "releases": []}
106
+ complete = True
107
+ # Atom feeds avoid spending GitHub API quota on public releases/commits.
108
+ for kind, url in [("releases", f"https://github.com/{name}/releases.atom"),
109
+ ("commits", f"https://github.com/{name}/commits/{urllib.parse.quote(repo['branch'], safe='')}.atom")]:
110
+ try:
111
+ raw, _ = fetch(url)
112
+ items = atom_entries(raw)
113
+ seen = set(prior.get(kind, []))
114
+ result[kind] = [x for x in items if x["id"] not in seen]
115
+ result["sources"][kind] = {"ok": True, "url": url, "ids": [x["id"] for x in items], "scope": "latest feed entries; older entries require source review"}
116
+ except Exception as error:
117
+ try:
118
+ endpoint = f"https://api.github.com/repos/{name}/releases?per_page=30" if kind == "releases" else f"https://api.github.com/repos/{name}/commits?" + urllib.parse.urlencode({"sha": repo["branch"], "per_page": 30})
119
+ raw, _ = fetch(endpoint, token)
120
+ values = json.loads(raw)
121
+ if not isinstance(values, list):
122
+ raise RuntimeError("unexpected fallback API response")
123
+ items = [{"id": str(x.get("id") or x.get("sha")), "title": x.get("name") or x.get("tag_name") or x.get("commit", {}).get("message", "").split("\n")[0],
124
+ "updated_at": x.get("published_at") or x.get("commit", {}).get("committer", {}).get("date"), "url": x.get("html_url")} for x in values]
125
+ seen = set(prior.get(kind, []))
126
+ result[kind] = [x for x in items if x["id"] not in seen]
127
+ result["sources"][kind] = {"ok": True, "url": endpoint, "ids": [x["id"] for x in items], "fallback": True, "scope": "latest 30 entries; older entries require source review"}
128
+ except Exception as fallback_error:
129
+ complete = False
130
+ result["sources"][kind] = {"ok": False, "url": url}
131
+ result["errors"].append(f"{kind}: {type(fallback_error).__name__}: {fallback_error}")
132
+ try:
133
+ rows = []
134
+ max_pages = 50 if token else 2
135
+ for page in range(1, max_pages + 1):
136
+ query = urllib.parse.urlencode({"state": "all", "sort": "updated", "direction": "desc", "since": since, "per_page": 100, "page": page})
137
+ raw, headers = fetch(f"https://api.github.com/repos/{name}/issues?{query}", token)
138
+ batch = json.loads(raw)
139
+ if not isinstance(batch, list):
140
+ raise RuntimeError("unexpected GitHub issue response")
141
+ rows.extend(batch)
142
+ if len(batch) < 100:
143
+ break
144
+ partial = len(batch) == 100
145
+ result["sources"]["issues"] = {"ok": True, "partial": partial, "fetched": len(rows), "quota_remaining": headers.get("X-RateLimit-Remaining", headers.get("x-ratelimit-remaining"))}
146
+ complete = complete and not partial
147
+ # Retain every fetched title and status. Mark topics to help review without discarding evidence.
148
+ result["issues"] = [{"number": x["number"], "title": x["title"], "state": x["state"], "updated_at": x["updated_at"],
149
+ "url": x["html_url"], "pr": "pull_request" in x, "comments": x.get("comments", 0),
150
+ "topic_match": bool(TOPICS.search(x["title"]))} for x in rows]
151
+ if partial:
152
+ result["errors"].append(f"issues: {max_pages * 100}-entry cap reached; older changes remain unscanned, checkpoint not advanced")
153
+ except Exception as error:
154
+ complete = False
155
+ result["sources"]["issues"] = {"ok": False}
156
+ result["errors"].append(f"issues: {type(error).__name__}: {error}")
157
+ result["complete"] = complete
158
+ return result
159
+
160
+
161
+ def main():
162
+ parser = argparse.ArgumentParser()
163
+ default_home = Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")))
164
+ parser.add_argument("--manifest", type=Path, default=default_home / "skills/devops/gateway-upstream-watch/references/repos.json")
165
+ parser.add_argument("--state-dir", type=Path, default=default_home / "gateway-upstream-watch")
166
+ parser.add_argument("--only", help="one repository for a bounded smoke check")
167
+ parser.add_argument("--no-checkpoint", action="store_true", help="probe without advancing persistent scan state")
168
+ args = parser.parse_args()
169
+ manifest = read_json(args.manifest, None)
170
+ if manifest is None:
171
+ raise RuntimeError("verified repository manifest is missing")
172
+ state_path = args.state_dir / "scan-state.json"
173
+ state = read_json(state_path, {"repos": {}, "docs": {}})
174
+ now = datetime.now(timezone.utc)
175
+ token = github_token()
176
+ repos = [r for r in manifest["repos"] if not args.only or r["repo"] == args.only]
177
+ if not repos:
178
+ raise RuntimeError("requested repository is not in the verified manifest")
179
+ report = {"checked_at": now.isoformat(), "authenticated_github": bool(token), "requested_repos": len(repos), "repos": [], "docs": [],
180
+ "review_required": True, "scope": "GitHub issue metadata plus latest release/commit feeds; the agent must read source/diffs for conclusions"}
181
+ with ThreadPoolExecutor(max_workers=3) as pool:
182
+ tasks = {}
183
+ for repo in repos:
184
+ prior = state["repos"].get(repo["repo"], {})
185
+ checkpoint = prior.get("checkpoint")
186
+ since = (datetime.fromisoformat(checkpoint) - timedelta(hours=2) if checkpoint else now - timedelta(days=2)).isoformat()
187
+ tasks[pool.submit(scan_repo, repo, since, prior, token)] = repo
188
+ for task in as_completed(tasks):
189
+ result = task.result()
190
+ report["repos"].append(result)
191
+ prior = state["repos"].setdefault(result["repo"], {})
192
+ if result["complete"]:
193
+ prior["checkpoint"] = now.isoformat()
194
+ for kind in ("commits", "releases"):
195
+ if result["sources"][kind]["ok"]:
196
+ prior[kind] = result["sources"][kind]["ids"]
197
+ if not args.only:
198
+ for url in manifest["docs"]:
199
+ try:
200
+ raw, _ = fetch(url)
201
+ page = PageText()
202
+ page.feed(raw.decode("utf-8", errors="replace"))
203
+ digest = hashlib.sha256(" ".join(page.parts).encode()).hexdigest()
204
+ old = state["docs"].get(url)
205
+ report["docs"].append({"url": url, "ok": True, "changed": old != digest, "first_check": old is None, "hash": digest})
206
+ state["docs"][url] = digest
207
+ except Exception as error:
208
+ report["docs"].append({"url": url, "ok": False, "error": f"{type(error).__name__}: {error}"})
209
+ report["repos"].sort(key=lambda x: x["repo"])
210
+ report["complete_repos"] = sum(r["complete"] for r in report["repos"])
211
+ report["partial"] = report["complete_repos"] != len(repos) or any(not d["ok"] for d in report["docs"])
212
+ target = args.state_dir / "evidence" / f"{now.strftime('%Y-%m-%dT%H-%M-%S')}.json"
213
+ atomic_json(target, report)
214
+ atomic_json(args.state_dir / "latest.json", report)
215
+ if not args.no_checkpoint:
216
+ atomic_json(state_path, state)
217
+ print(json.dumps({"evidence_file": str(target), "requested_repos": len(repos), "complete_repos": report["complete_repos"], "partial": report["partial"],
218
+ "instruction": "Read the evidence JSON and gateway-upstream-watch skill. Report incomplete sources explicitly; metadata alone is not a verified fix."}, ensure_ascii=False))
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.5",
3
+ "version": "1.6.7",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // preflight-check.mjs — Proactively verify configured models, API key, and endpoint
3
+ // availability when launching a tool session from the notice shim.
4
+ //
5
+ // Usage: node preflight-check.mjs <tool> [stateDir]
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const tool = process.argv[2] || 'claude';
11
+ const stateDir = process.argv[3] || process.env.LLM_SWITCHER_STATE_DIR || path.join(process.env.HOME || '', '.llm-switcher');
12
+ const CACHE_TTL_MS = 180000; // 3 minutes cache to keep consecutive shell starts instant
13
+
14
+ async function run() {
15
+ const routeFile = path.join(stateDir, `route-${tool}.txt`);
16
+ let routeLine = '';
17
+ try {
18
+ if (fs.existsSync(routeFile)) {
19
+ routeLine = fs.readFileSync(routeFile, 'utf8').trim();
20
+ }
21
+ } catch {}
22
+
23
+ if (!routeLine) {
24
+ process.exit(0);
25
+ }
26
+
27
+ const configPath = path.join(stateDir, 'config.json');
28
+ if (!fs.existsSync(configPath)) {
29
+ console.log(`[llm-switcher] ${routeLine}`);
30
+ process.exit(0);
31
+ }
32
+
33
+ let cfg;
34
+ try {
35
+ cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
36
+ } catch {
37
+ console.log(`[llm-switcher] ${routeLine}`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const activeMap = cfg.activeProfiles || {};
42
+ const profileKey = activeMap[tool] || (tool === 'claude' ? cfg.activeProfile : null);
43
+ const profile = profileKey ? cfg.profiles?.[profileKey] : null;
44
+
45
+ if (!profile || !profile.baseURL) {
46
+ console.log(`[llm-switcher] ${routeLine}`);
47
+ process.exit(0);
48
+ }
49
+
50
+ // Identify configured models for this tool
51
+ const configuredModels = [];
52
+ if (tool === 'claude') {
53
+ const dm = profile.defaultModels || {};
54
+ if (dm.sonnet) configuredModels.push({ slot: 'sonnet', name: dm.sonnet });
55
+ if (dm.opus && dm.opus !== dm.sonnet) configuredModels.push({ slot: 'opus', name: dm.opus });
56
+ if (dm.haiku && dm.haiku !== dm.sonnet) configuredModels.push({ slot: 'haiku', name: dm.haiku });
57
+ } else if (tool === 'codex') {
58
+ const dm = profile.defaultModels || {};
59
+ if (dm.main) configuredModels.push({ slot: 'main', name: dm.main });
60
+ if (dm.review && dm.review !== dm.main) configuredModels.push({ slot: 'review', name: dm.review });
61
+ if (dm.subagent && dm.subagent !== dm.main) configuredModels.push({ slot: 'subagent', name: dm.subagent });
62
+ } else if (tool === 'agy') {
63
+ const m = profile.defaultModels?.main || profile.model || profile.models?.[0];
64
+ if (m) configuredModels.push({ slot: 'main', name: m });
65
+ }
66
+
67
+ if (!configuredModels.length && profile.model) {
68
+ configuredModels.push({ slot: 'default', name: profile.model });
69
+ }
70
+
71
+ const cachePath = path.join(stateDir, 'preflight-cache.json');
72
+ const cacheKey = `${profileKey}:${configuredModels.map(m => m.name).join(',')}`;
73
+
74
+ // Check cache
75
+ try {
76
+ if (fs.existsSync(cachePath)) {
77
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
78
+ const entry = cache[tool];
79
+ if (entry && entry.cacheKey === cacheKey && (Date.now() - entry.time < CACHE_TTL_MS)) {
80
+ if (Array.isArray(entry.lines)) {
81
+ console.log(entry.lines.join('\n'));
82
+ process.exit(0);
83
+ }
84
+ }
85
+ }
86
+ } catch {}
87
+
88
+ // Run preflight check
89
+ let lines = [];
90
+ const base = String(profile.baseURL || '').replace(/\/+$/, '');
91
+ const modelsUrl = base.endsWith('/v1') ? `${base}/models` : `${base}/models`;
92
+
93
+ const headers = {
94
+ 'user-agent': 'llm-switcher/preflight',
95
+ 'accept': 'application/json'
96
+ };
97
+
98
+ if (profile.outFormat === 'anthropic') {
99
+ headers['x-api-key'] = profile.apiKey || '';
100
+ headers['anthropic-version'] = '2023-06-01';
101
+ } else if (profile.apiKey) {
102
+ headers['authorization'] = `Bearer ${profile.apiKey}`;
103
+ }
104
+
105
+ try {
106
+ const res = await fetch(modelsUrl, {
107
+ method: 'GET',
108
+ headers,
109
+ signal: AbortSignal.timeout(2500)
110
+ });
111
+
112
+ if (res.status === 401 || res.status === 403) {
113
+ lines = [
114
+ `[llm-switcher] ${routeLine}`,
115
+ `[llm-switcher:preflight] ⚠️ CẢNH BÁO: API key cho profile '${profileKey}' không hợp lệ hoặc bị từ chối (HTTP ${res.status}).`,
116
+ `[llm-switcher:preflight] Gợi ý: Chạy 'switch ui' để kiểm tra lại API key.`
117
+ ];
118
+ } else if (res.status >= 500) {
119
+ lines = [
120
+ `[llm-switcher] ${routeLine}`,
121
+ `[llm-switcher:preflight] ⚠️ CẢNH BÁO: Endpoint ${base} đang báo lỗi máy chủ (HTTP ${res.status}).`
122
+ ];
123
+ } else if (res.ok) {
124
+ let json = {};
125
+ try {
126
+ json = await res.json();
127
+ } catch {}
128
+
129
+ const rawList = Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : [];
130
+ const catalogIds = new Set(rawList.map(m => typeof m === 'string' ? m : m?.id).filter(Boolean));
131
+
132
+ if (catalogIds.size > 0 && configuredModels.length > 0) {
133
+ const missing = [];
134
+ for (const m of configuredModels) {
135
+ const shortName = m.name.split('/').pop();
136
+ const found = catalogIds.has(m.name) || catalogIds.has(shortName) ||
137
+ Array.from(catalogIds).some(id => id.endsWith(m.name) || m.name.endsWith(id));
138
+ if (!found) {
139
+ missing.push(m);
140
+ }
141
+ }
142
+
143
+ if (missing.length > 0) {
144
+ lines = [
145
+ `[llm-switcher] ${routeLine}`,
146
+ `[llm-switcher:preflight] ⚠️ CẢNH BÁO: Model đã setup không còn khả dụng trên provider: ${missing.map(m => `'${m.name}' [slot ${m.slot}]`).join(', ')}.`,
147
+ `[llm-switcher:preflight] Gợi ý: Chạy 'switch ui' để cập nhật lại model hợp lệ.`
148
+ ];
149
+ } else {
150
+ lines = [`[llm-switcher] ${routeLine} (verified ✓)`];
151
+ }
152
+ } else {
153
+ lines = [`[llm-switcher] ${routeLine} (verified ✓)`];
154
+ }
155
+ } else {
156
+ // 404 on /models (provider doesn't implement catalog endpoint) -> neutral fallback
157
+ lines = [`[llm-switcher] ${routeLine}`];
158
+ }
159
+ } catch (err) {
160
+ if (err.name === 'TimeoutError') {
161
+ lines = [
162
+ `[llm-switcher] ${routeLine}`,
163
+ `[llm-switcher:preflight] ⚠️ CẢNH BÁO: Endpoint ${base} phản hồi chậm (>2.5s) hoặc không thể kết nối.`
164
+ ];
165
+ } else {
166
+ lines = [
167
+ `[llm-switcher] ${routeLine}`,
168
+ `[llm-switcher:preflight] ⚠️ CẢNH BÁO: Không thể kết nối tới endpoint ${base} (${err.message || 'connection failed'}).`
169
+ ];
170
+ }
171
+ }
172
+
173
+ // Save cache
174
+ try {
175
+ let cache = {};
176
+ if (fs.existsSync(cachePath)) {
177
+ try { cache = JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch {}
178
+ }
179
+ cache[tool] = { cacheKey, time: Date.now(), lines };
180
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
181
+ } catch {}
182
+
183
+ console.log(lines.join('\n'));
184
+ }
185
+
186
+ run().catch(() => {
187
+ process.exit(0);
188
+ });