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.
- package/CHANGELOG.md +12 -0
- package/compaction.mjs +72 -0
- package/docs/goal-check.md +75 -0
- package/formats.mjs +107 -35
- package/goalcheck-call.mjs +208 -0
- package/goalcheck.mjs +498 -0
- package/maintenance/gateway-upstream-watch/REPOSITORIES.md +64 -0
- package/maintenance/gateway-upstream-watch/SKILL.md +18 -0
- package/maintenance/gateway-upstream-watch/prompt.vi.md +11 -0
- package/maintenance/gateway-upstream-watch/references/repos.json +570 -0
- package/maintenance/gateway-upstream-watch/watch.py +222 -0
- package/package.json +1 -1
- package/preflight-check.mjs +188 -0
- package/proxy.mjs +393 -53
- package/shim.mjs +13 -2
- package/state.mjs +5 -0
- package/tests/codex-daemon.test.mjs +1 -0
- package/tests/compaction.e2e.test.mjs +79 -0
- package/tests/gateway-contract.test.mjs +59 -0
- package/tests/gateway.e2e.test.mjs +47 -0
- package/tests/goalcheck.e2e.test.mjs +316 -0
- package/tests/goalcheck.test.mjs +312 -0
- package/tests/preflight.test.mjs +119 -0
- package/tests/tool-repair.test.mjs +119 -2
- package/tests/update.test.mjs +2 -2
- package/tests/upstream-stream.test.mjs +30 -0
- package/toolvocab.mjs +59 -11
- package/ui.html +676 -1
- package/upstream-stream.mjs +37 -0
|
@@ -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
|
@@ -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
|
+
});
|