llm-switcher 1.6.5 → 1.6.6

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.6",
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",
package/proxy.mjs CHANGED
@@ -27,7 +27,9 @@ import {
27
27
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
28
  } from './state.mjs';
29
29
  import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
30
- import { createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
30
+ import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
31
+ import { readUpstreamPayloads } from './upstream-stream.mjs';
32
+ import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
31
33
  import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
32
34
  import { checkForUpdate } from './version.mjs';
33
35
  import { applyUpdate } from './update.mjs';
@@ -454,53 +456,13 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
454
456
  return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
455
457
  }
456
458
 
457
- // Read the upstream stream: accept both SSE `data:` and raw JSON lines (Vertex framing).
458
- async function* readUpstreamPayloads(upstreamRes) {
459
- const reader = upstreamRes.body.getReader();
460
- const decoder = new TextDecoder('utf8');
461
- let buffer = '';
462
- let finished = false;
463
- try {
464
- while (true) {
465
- const { done, value } = await reader.read();
466
- if (done) {
467
- finished = true;
468
- break;
469
- }
470
- buffer += decoder.decode(value, { stream: true });
471
- const lines = buffer.split('\n');
472
- buffer = lines.pop();
473
- for (const line of lines) {
474
- const parsed = parsePayloadLine(line);
475
- if (parsed) yield parsed;
476
- }
477
- }
478
- buffer += decoder.decode();
479
- const tail = parsePayloadLine(buffer);
480
- if (tail) yield tail;
481
- } finally {
482
- // A consumer that stops early (an in-band error) must close the upstream connection too.
483
- if (!finished) await reader.cancel().catch(() => {});
484
- try { reader.releaseLock(); } catch {}
485
- }
486
- }
487
-
488
- function parsePayloadLine(line) {
489
- let s = line.trim();
490
- if (!s) return null;
491
- if (s.startsWith('data:')) s = s.slice(5).trim();
492
- else if (s.startsWith('event:') || s.startsWith(':') || s.startsWith('id:') || s.startsWith('retry:')) return null;
493
- if (!s || s === '[DONE]' || !s.startsWith('{')) return null;
494
- try { return JSON.parse(s); } catch { return null; }
495
- }
496
-
497
459
  // The tool vocabulary the caller declared, so a model that answers with a name from its own
498
460
  // training is written back in the caller's words instead of ending the turn with "No such tool
499
461
  // available". Built once per request from the tools the caller actually sent, so a request that
500
462
  // declared none pays nothing and a turn where the model agreed with the list changes nothing.
501
- function toolVocabFor(ir) {
463
+ function toolVocabFor(ir, outFormat) {
502
464
  if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
503
- if (!ir._vocab) ir._vocab = createToolVocab(ir.tools);
465
+ if (!ir._vocab) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
504
466
  return ir._vocab;
505
467
  }
506
468
 
@@ -937,7 +899,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
937
899
 
938
900
  let ir;
939
901
  try {
940
- ir = parseToIR(clientFormat, payload);
902
+ ir = parseToIR(clientFormat, payload, { allowOpaqueCompaction: true });
941
903
  } catch (e) {
942
904
  sendClientError(res, clientFormat, 400, `Cannot parse ${clientFormat} request: ${e.message}`);
943
905
  return;
@@ -955,7 +917,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
955
917
  const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
956
918
  if (shrunk) {
957
919
  try {
958
- const compactedIR = parseToIR(clientFormat, shrunk.payload);
920
+ const compactedIR = parseToIR(clientFormat, shrunk.payload, { allowOpaqueCompaction: true });
959
921
  payload = shrunk.payload;
960
922
  bodyBuffer = shrunk.bodyBuffer;
961
923
  ir = compactedIR;
@@ -1008,7 +970,9 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1008
970
  const headers = bifrostHeaders(req, profile);
1009
971
  if (traceId) headers['x-intact-trace'] = traceId;
1010
972
  try {
1011
- const r = await forwardAnthropicDirect(res, payload, bodyBuffer, bifrostURL(profile, clientFormat, req, opts), headers, mappedModel, ac.signal, profile, true);
973
+ const nativePayload = clientFormat === 'responses' ? expandGatewayCompactions(payload) : payload;
974
+ const nativeBytes = nativePayload === payload ? bodyBuffer : Buffer.from(JSON.stringify(nativePayload));
975
+ const r = await forwardAnthropicDirect(res, nativePayload, nativeBytes, bifrostURL(profile, clientFormat, req, opts), headers, mappedModel, ac.signal, profile, true);
1012
976
  answered = !r.error && r.status >= 200 && r.status < 300;
1013
977
  log({ status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
1014
978
  } catch (err) {
@@ -1038,6 +1002,10 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1038
1002
  return;
1039
1003
  }
1040
1004
 
1005
+ if (ir.opaqueCompaction) {
1006
+ sendClientError(res, clientFormat, 400, 'Cannot convert native or legacy compaction state; resume through its original Responses provider.');
1007
+ return log({ status: 400, error: 'unsupported compaction state' });
1008
+ }
1041
1009
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1042
1010
  const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1043
1011
  if (traceId) headers['x-intact-trace'] = traceId;
@@ -1063,6 +1031,24 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1063
1031
  const normalize = createUpstreamNormalizer(outFormat);
1064
1032
  const col = createCollector();
1065
1033
 
1034
+ if (ir.compaction) {
1035
+ try {
1036
+ await collectCompaction(upstreamRes, outFormat, ir.stream, normalize, col);
1037
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1038
+ const response = compactionResponse(col, requestedModel || mappedModel, text => splitThinkTags(text).text);
1039
+ if (ir.stream) {
1040
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
1041
+ emitCompaction(response, (event, data) => sendSSE(res, event, data)); res.end();
1042
+ } else sendJson(res, 200, response);
1043
+ answered = true;
1044
+ return log({ status: 200, responsePreview: '(compaction)', tokens: { prompt: col.prompt, completion: col.completion() } });
1045
+ } catch (err) {
1046
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
1047
+ sendClientError(res, clientFormat, 502, err.message);
1048
+ return log({ status: 502, error: err.message });
1049
+ }
1050
+ }
1051
+
1066
1052
  // ---- non-stream ----
1067
1053
  if (!ir.stream) {
1068
1054
  let json;
@@ -1085,7 +1071,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1085
1071
  const out = clientMessage(clientFormat, {
1086
1072
  model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
1087
1073
  finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
1088
- reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir)
1074
+ reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir, outFormat)
1089
1075
  });
1090
1076
  sendJson(res, 200, out);
1091
1077
  answered = true;
@@ -1102,7 +1088,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1102
1088
  'Connection': 'keep-alive',
1103
1089
  'X-Accel-Buffering': 'no'
1104
1090
  });
1105
- const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir) });
1091
+ const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
1106
1092
  renderer.start();
1107
1093
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
1108
1094
  const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
@@ -2019,7 +2005,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
2019
2005
  // previous_response_id; the server keeps the rest. The gateway keeps it per socket, bounded.
2020
2006
  const WS_HISTORY_MAX = 8;
2021
2007
  // Only these output items are valid input for the next turn; reasoning items are not replayed.
2022
- const WS_REPLAY_TYPES = new Set(['message', 'function_call', 'custom_tool_call', 'local_shell_call']);
2008
+ const WS_REPLAY_TYPES = new Set(['message', 'function_call', 'custom_tool_call', 'local_shell_call', 'compaction']);
2023
2009
 
2024
2010
  function wsInputItems(input) {
2025
2011
  if (typeof input === 'string') return input ? [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: input }] }] : [];
@@ -2169,7 +2155,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2169
2155
 
2170
2156
  const bifrostModel = mapModel(payload.model || '', profile, clientFormat);
2171
2157
  if (await crossesBifrost(req, profile, bifrostModel)) {
2172
- return wsBifrostTurn({ socket, payload, req, ac, history, input, profile, profileKey, mappedModel: bifrostModel });
2158
+ return wsBifrostTurn({ socket, payload: expandGatewayCompactions(payload), req, ac, history, input, profile, profileKey, mappedModel: bifrostModel });
2173
2159
  }
2174
2160
 
2175
2161
  let ir;
@@ -2224,6 +2210,21 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2224
2210
  const normalize = createUpstreamNormalizer(outFormat);
2225
2211
  const col = createCollector();
2226
2212
 
2213
+ if (ir.compaction) {
2214
+ try {
2215
+ await collectCompaction(upstreamRes, outFormat, true, normalize, col);
2216
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2217
+ const response = compactionResponse(col, requestedModel || mappedModel, text => splitThinkTags(text).text);
2218
+ emitCompaction(response, (event, data) => { tapWsEvent(halfTap, event, data); if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(data))); });
2219
+ rememberWsTurn(history, response, []); answered = true;
2220
+ return log({ status: 200, responsePreview: '(compaction)', tokens: { prompt: col.prompt, completion: col.completion() } });
2221
+ } catch (err) {
2222
+ if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
2223
+ sendWsFailed(socket, mappedModel, err.message, 502, halfTap);
2224
+ return log({ status: 502, error: err.message });
2225
+ }
2226
+ }
2227
+
2227
2228
  let completedResponse = null;
2228
2229
  const renderer = createResponsesStream((e, d) => {
2229
2230
  tapWsEvent(halfTap, e, d);
@@ -2231,7 +2232,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2231
2232
  if (socket.writable) {
2232
2233
  socket.write(encodeWsFrame(JSON.stringify(d)));
2233
2234
  }
2234
- }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir) });
2235
+ }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
2235
2236
 
2236
2237
  renderer.start();
2237
2238
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
@@ -12,6 +12,7 @@ import { buildCerts } from '../blindfold/certs.mjs';
12
12
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
13
13
  process.env.HOME = path.join(root, 'home');
14
14
  process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
15
+ process.env.LLM_SWITCHER_BLINDFOLD_CERTS = path.join(root, 'certs');
15
16
  delete process.env.CODEX_HOME;
16
17
  fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
17
18
  const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, computeLaunchState, STATE_DIR, INTERCEPT_HOSTS, usableCACertificate } = await import('../state.mjs');
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { openResponsesWs } from './ws-client.mjs';
10
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
11
+ const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
12
+ async function until(check) { const deadline = Date.now() + 6000; while (Date.now() < deadline) { if (await check()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('timeout'); }
13
+ async function fixture(t) {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compaction-'));
15
+ const received = []; let finish = 'stop', truncate = false;
16
+ const up = http.createServer((req, res) => {
17
+ let raw = ''; req.on('data', b => raw += b); req.on('end', () => {
18
+ if (req.method === 'GET') return res.end('{"data":[]}');
19
+ const body = JSON.parse(raw); received.push(body);
20
+ const summary = 'KEEP_DECISION_7391';
21
+ if (body.stream) {
22
+ res.setHeader('content-type', 'text/event-stream');
23
+ res.write(`data: {"choices":\ndata: [{"index":0,"delta":{"content":"${summary}"}}]}\n\n`);
24
+ if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":"${finish}"}]}\n\ndata: [DONE]\n\n`);
25
+ res.end();
26
+ } else { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { content: summary }, finish_reason: finish }] })); }
27
+ });
28
+ }); await listen(up);
29
+ const reserve = http.createServer(); await listen(reserve); const port = reserve.address().port; await new Promise(r => reserve.close(r));
30
+ const config = path.join(dir, 'config.json'); fs.writeFileSync(config, JSON.stringify({ port, profiles: {
31
+ p1: { name: 'Offline', mode: 'convert', outFormat: 'openai-chat', baseURL: `http://127.0.0.1:${up.address().port}/v1`, apiKey: 'fixture-only' }
32
+ } }));
33
+ const child = spawn(process.execPath, [path.join(root, 'proxy.mjs')], { env: { ...process.env,
34
+ LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir, LLM_SWITCHER_PORT: '', LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' }, stdio: ['ignore', 'pipe', 'pipe'] });
35
+ let logs = ''; child.stdout.on('data', b => logs += b); child.stderr.on('data', b => logs += b);
36
+ t.after(async () => {
37
+ if (child.exitCode === null) { const done = new Promise(r => child.once('exit', r)); child.kill(); await done; }
38
+ up.closeAllConnections?.(); await new Promise(r => up.close(r));
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ });
41
+ await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
42
+ const send = body => fetch(`http://127.0.0.1:${port}/v1/responses`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1' }, body: JSON.stringify(body) });
43
+ return { port, received, send, failure: (reason, cut = false) => { finish = reason; truncate = cut; }, logs: () => logs };
44
+ }
45
+ const trigger = stream => ({ model: 'm', stream, input: [{ role: 'user', content: 'ORIGINAL_HISTORY' }, { type: 'compaction_trigger' }] });
46
+ const events = text => text.split('\n').filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
47
+
48
+ test('HTTP compaction produces one item, replays it, and can compact the replay again', { timeout: 20000 }, async t => {
49
+ const f = await fixture(t);
50
+ for (const stream of [false, true]) {
51
+ const first = await f.send(trigger(stream)); assert.equal(first.status, 200, f.logs());
52
+ const response = stream ? events(await first.text()).find(e => e.type === 'response.completed').response : await first.json();
53
+ assert.equal(response.output.length, 1); assert.equal(response.output[0].type, 'compaction');
54
+ const replay = await f.send({ model: 'm', input: [response.output[0], { role: 'user', content: 'continue' }] });
55
+ assert.equal(replay.status, 200, await replay.text());
56
+ assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*continue/);
57
+ const again = await f.send({ ...trigger(false), input: [response.output[0], { type: 'compaction_trigger' }] });
58
+ assert.equal(again.status, 200, await again.text());
59
+ assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391/);
60
+ assert.doesNotMatch(JSON.stringify(f.received.at(-1)), /encrypted_content|compaction_trigger/);
61
+ }
62
+ });
63
+ test('HTTP compaction rejects token-limit and prematurely ended summaries', { timeout: 20000 }, async t => {
64
+ const f = await fixture(t);
65
+ f.failure('length'); assert.equal((await f.send(trigger(false))).status, 502);
66
+ f.failure('stop', true); const r = await f.send(trigger(true)); assert.equal(r.status, 502);
67
+ assert.doesNotMatch(await r.text(), /encrypted_content/);
68
+ });
69
+ test('WS compaction replays the compacted state through previous_response_id', { timeout: 20000 }, async t => {
70
+ const f = await fixture(t); const ws = await openResponsesWs(f.port, { 'x-profile': 'p1' }); t.after(ws.close);
71
+ ws.send({ type: 'response.create', ...trigger(true) });
72
+ await until(() => ws.messages.some(m => ['response.completed', 'response.failed'].includes(m.type)));
73
+ const completed = ws.messages.find(m => m.type === 'response.completed'); assert.ok(completed, JSON.stringify(ws.messages));
74
+ assert.equal(ws.messages.filter(m => m.type === 'response.output_item.done' && m.item.type === 'compaction').length, 1);
75
+ ws.send({ type: 'response.create', model: 'm', previous_response_id: completed.response.id, input: [{ role: 'user', content: 'NEXT_TURN' }] });
76
+ await until(() => f.received.length === 2);
77
+ assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*NEXT_TURN/);
78
+ assert.doesNotMatch(JSON.stringify(f.received.at(-1).messages), /ORIGINAL_HISTORY/);
79
+ });
@@ -0,0 +1,59 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { anthropicToIR, responsesToIR, irToChatBody, irToVertexBody, irToAnthropicBody } from '../formats.mjs';
4
+ import { createToolVocab } from '../toolvocab.mjs';
5
+
6
+ const prefix = 'llm-gateway-compact-v1:';
7
+ const compact = summary => ({ type: 'compaction', encrypted_content: prefix + Buffer.from(JSON.stringify({ summary })).toString('base64url') });
8
+
9
+ test('converted Responses replays gateway compaction and refuses unknown ciphertext', () => {
10
+ const body = irToChatBody(responsesToIR({ input: [compact('KEEP_DECISION'), { role: 'user', content: 'continue' }] }), 'm');
11
+ assert.match(JSON.stringify(body.messages), /KEEP_DECISION/);
12
+ assert.doesNotMatch(JSON.stringify(body.messages), /llm-gateway-compact/);
13
+ assert.throws(() => responsesToIR({ input: [{ type: 'compaction', encrypted_content: 'opaque-native-ciphertext' }] }), /compaction/i);
14
+ });
15
+
16
+ test('explicit true and false strict survive Responses to Chat', () => {
17
+ for (const strict of [true, false]) {
18
+ const ir = responsesToIR({ input: 'go', tools: [{ type: 'function', name: 'f', strict,
19
+ parameters: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false } }] });
20
+ assert.equal(irToChatBody(ir, 'm').tools[0].function.strict, strict);
21
+ }
22
+ });
23
+
24
+ test('Vertex tool names remain unique and reverse to the correct declaration, history and choice', () => {
25
+ const names = ['mcp__server-a__read', 'mcp__server_a__read'];
26
+ for (const order of [names, [...names].reverse()]) {
27
+ const ir = anthropicToIR({ tools: order.map(name => ({ name, input_schema: { type: 'object' } })), tool_choice: { type: 'tool', name: names[0] },
28
+ messages: [{ role: 'assistant', content: names.map((name, i) => ({ type: 'tool_use', id: `c${i}`, name, input: {} })) },
29
+ { role: 'user', content: names.map((_, i) => ({ type: 'tool_result', tool_use_id: `c${i}`, content: 'ok' })) }] });
30
+ const body = irToVertexBody(ir, 'gemini-2.5-pro');
31
+ const emitted = body.tools[0].functionDeclarations.map(t => t.name);
32
+ assert.equal(new Set(emitted).size, 2);
33
+ const vocab = createToolVocab(ir.tools, { allowHyphens: false });
34
+ assert.deepEqual(emitted.map(n => vocab.name(n)), order);
35
+ const calls = body.contents.find(c => c.role === 'model').parts.map(p => p.functionCall.name);
36
+ assert.deepEqual(calls.map(n => vocab.name(n)), names);
37
+ assert.equal(vocab.name(body.toolConfig.functionCallingConfig.allowedFunctionNames[0]), names[0]);
38
+ assert.equal(emitted[order.indexOf(names[1])], names[1], 'valid original stays reserved');
39
+ }
40
+ });
41
+
42
+ test('tool-result image reaches Chat, Anthropic and Vertex without becoming text', () => {
43
+ const ir = anthropicToIR({ messages: [
44
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'image-call', name: 'screen', input: {} }] },
45
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'image-call', content: [
46
+ { type: 'text', text: 'screenshot' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }
47
+ ] }] }
48
+ ] });
49
+ const chat = irToChatBody(ir, 'm');
50
+ const tool = chat.messages.find(m => m.role === 'tool');
51
+ assert.equal(tool.content, 'screenshot');
52
+ const image = chat.messages.find(m => m.role === 'user' && Array.isArray(m.content));
53
+ assert.equal(image.content.find(p => p.type === 'image_url').image_url.url, 'data:image/png;base64,iVBORw0KGgo=');
54
+ const anthropic = irToAnthropicBody(ir, 'm');
55
+ assert.equal(anthropic.messages.at(-1).content[0].content[1].source.data, 'iVBORw0KGgo=');
56
+ const vertex = irToVertexBody(ir, 'gemini-2.5-pro');
57
+ assert.match(JSON.stringify(vertex.contents), /"inlineData":\{"mimeType":"image\/png","data":"iVBORw0KGgo="\}/);
58
+ assert.doesNotMatch(tool.content, /omitted|base64/);
59
+ });
@@ -95,6 +95,19 @@ function startUpstream() {
95
95
  return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
96
96
  }
97
97
  if (req.url.startsWith('/chat/v1/chat/completions')) {
98
+ if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
99
+ const reference = json.messages.find(m => m.role === 'tool');
100
+ const name = JSON.parse(reference.content).tool_name;
101
+ if (!json.tools.some(t => t.function.name === name)) {
102
+ return sse(res, [{ error: { message: `upstream called undeclared tool '${name}'`, type: 'server_error' } }]);
103
+ }
104
+ return sse(res, [
105
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'console1', type: 'function', function: { name, arguments: '{"pageSize":0}' } }] } }] },
106
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 1, id: 'search2', type: 'function', function: { name: 'ToolSearch', arguments: '{"query":"select:' } }] } }] },
107
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 1, function: { arguments: `${name}","max_results":5}` } }] } }] },
108
+ { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
109
+ ], { raw: ['data: [DONE]\n\n'] });
110
+ }
98
111
  if (lastUser.includes('RATE_LIMIT')) {
99
112
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
100
113
  return res.end(JSON.stringify({ error: { message: 'slow down' } }));
@@ -650,6 +663,40 @@ test('Bifrost stays off for a model that is not a Claude Code account', async ()
650
663
  assert.equal(received.at(-1).url, '/intact/v1/chat/completions');
651
664
  });
652
665
 
666
+ test('ToolSearch replay calls declared upstream aliases and restores original tool names and queries to Claude', async () => {
667
+ const name = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
668
+ const res = await post('/v1/messages', {
669
+ model: 'claude-opus-4-6', max_tokens: 256, stream: true, system: `Use ${name} to inspect errors.`,
670
+ tools: [
671
+ { name: 'ToolSearch', input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'integer' } } } },
672
+ { name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }
673
+ ],
674
+ messages: [
675
+ { role: 'user', content: 'Inspect errors.' },
676
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${name}` } }] },
677
+ { role: 'user', content: [
678
+ { type: 'tool_result', tool_use_id: 'search1', content: [{ type: 'tool_reference', tool_name: name }] },
679
+ { type: 'text', text: 'TOOL_ALIAS_REPLAY' }
680
+ ] }
681
+ ]
682
+ });
683
+ assert.equal(res.status, 200);
684
+ const events = parseSSE(await res.text());
685
+ assertValidAnthropicEvents(events);
686
+ assert.ok(events.some(e => e.event === 'message_stop'));
687
+ assert.ok(!events.some(e => e.event === 'error'));
688
+ const starts = events.filter(e => e.data?.content_block?.type === 'tool_use');
689
+ assert.deepEqual(starts.map(e => e.data.content_block.name), [name, 'ToolSearch']);
690
+ const args = start => JSON.parse(events.filter(e => e.data?.index === start.data.index && e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join(''));
691
+ assert.deepEqual(args(starts[0]), { pageSize: 0 });
692
+ assert.deepEqual(args(starts[1]), { query: `select:${name}`, max_results: 5 });
693
+ const up = received.at(-1).body;
694
+ const alias = up.tools.find(t => t.function.name !== 'ToolSearch').function.name;
695
+ assert.equal(alias.length, 64);
696
+ assert.ok(!JSON.stringify(up.messages).includes(name));
697
+ assert.equal(JSON.parse(up.messages.find(m => m.tool_calls).tool_calls[0].function.arguments).query, `select:${alias}`);
698
+ });
699
+
653
700
  test('Bifrost stays off for a client that is not Claude Code', async () => {
654
701
  await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
655
702
  { 'x-llm-profile': 'intactcc', 'user-agent': 'some-sdk/1.0' });