llm-switcher 1.6.4 → 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.4",
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 } 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,58 +456,14 @@ 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
- let toolVocabCache = null;
502
- let toolVocabForIr = null;
503
- function toolVocabFor(ir) {
504
- if (toolVocabForIr !== ir) {
505
- toolVocabForIr = ir;
506
- toolVocabCache = createToolVocab(ir?.tools);
507
- }
508
- return toolVocabCache;
463
+ function toolVocabFor(ir, outFormat) {
464
+ if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
465
+ if (!ir._vocab) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
466
+ return ir._vocab;
509
467
  }
510
468
 
511
469
  function clientRenderer(clientFormat, res, model, opts = {}) {
@@ -918,6 +876,21 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
918
876
  return;
919
877
  }
920
878
 
879
+ // Handle Claude Code tether (message threads): reject with thread_unsupported_request
880
+ // so Claude Code immediately falls back to stateless mode and retries.
881
+ if (clientFormat === 'anthropic' && (payload?.thread || ((req.headers['anthropic-beta'] || '').includes('message-threads') && payload?.thread))) {
882
+ res.writeHead(400, { 'Content-Type': 'application/json' });
883
+ res.end(JSON.stringify({
884
+ type: 'error',
885
+ error: {
886
+ type: 'invalid_request_error',
887
+ message: 'Message threads (tether) are not supported by this upstream gateway.',
888
+ error_code: 'thread_unsupported_request'
889
+ }
890
+ }));
891
+ return;
892
+ }
893
+
921
894
  const wantIn = profile.inFormat || 'auto';
922
895
  if (wantIn !== 'auto' && wantIn !== clientFormat) {
923
896
  sendClientError(res, clientFormat, 400, `Profile "${profileKey}" expects "${wantIn}" input, got "${clientFormat}"`);
@@ -926,7 +899,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
926
899
 
927
900
  let ir;
928
901
  try {
929
- ir = parseToIR(clientFormat, payload);
902
+ ir = parseToIR(clientFormat, payload, { allowOpaqueCompaction: true });
930
903
  } catch (e) {
931
904
  sendClientError(res, clientFormat, 400, `Cannot parse ${clientFormat} request: ${e.message}`);
932
905
  return;
@@ -944,7 +917,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
944
917
  const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
945
918
  if (shrunk) {
946
919
  try {
947
- const compactedIR = parseToIR(clientFormat, shrunk.payload);
920
+ const compactedIR = parseToIR(clientFormat, shrunk.payload, { allowOpaqueCompaction: true });
948
921
  payload = shrunk.payload;
949
922
  bodyBuffer = shrunk.bodyBuffer;
950
923
  ir = compactedIR;
@@ -997,7 +970,9 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
997
970
  const headers = bifrostHeaders(req, profile);
998
971
  if (traceId) headers['x-intact-trace'] = traceId;
999
972
  try {
1000
- 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);
1001
976
  answered = !r.error && r.status >= 200 && r.status < 300;
1002
977
  log({ status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
1003
978
  } catch (err) {
@@ -1027,6 +1002,10 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1027
1002
  return;
1028
1003
  }
1029
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
+ }
1030
1009
  const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
1031
1010
  const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
1032
1011
  if (traceId) headers['x-intact-trace'] = traceId;
@@ -1052,6 +1031,24 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1052
1031
  const normalize = createUpstreamNormalizer(outFormat);
1053
1032
  const col = createCollector();
1054
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
+
1055
1052
  // ---- non-stream ----
1056
1053
  if (!ir.stream) {
1057
1054
  let json;
@@ -1074,7 +1071,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1074
1071
  const out = clientMessage(clientFormat, {
1075
1072
  model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
1076
1073
  finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
1077
- reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir)
1074
+ reasoning: col.reasoning, sig: col.sig, vocab: toolVocabFor(ir, outFormat)
1078
1075
  });
1079
1076
  sendJson(res, 200, out);
1080
1077
  answered = true;
@@ -1091,7 +1088,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
1091
1088
  'Connection': 'keep-alive',
1092
1089
  'X-Accel-Buffering': 'no'
1093
1090
  });
1094
- 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) });
1095
1092
  renderer.start();
1096
1093
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
1097
1094
  const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
@@ -2008,7 +2005,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
2008
2005
  // previous_response_id; the server keeps the rest. The gateway keeps it per socket, bounded.
2009
2006
  const WS_HISTORY_MAX = 8;
2010
2007
  // Only these output items are valid input for the next turn; reasoning items are not replayed.
2011
- 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']);
2012
2009
 
2013
2010
  function wsInputItems(input) {
2014
2011
  if (typeof input === 'string') return input ? [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: input }] }] : [];
@@ -2158,7 +2155,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2158
2155
 
2159
2156
  const bifrostModel = mapModel(payload.model || '', profile, clientFormat);
2160
2157
  if (await crossesBifrost(req, profile, bifrostModel)) {
2161
- 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 });
2162
2159
  }
2163
2160
 
2164
2161
  let ir;
@@ -2213,6 +2210,21 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2213
2210
  const normalize = createUpstreamNormalizer(outFormat);
2214
2211
  const col = createCollector();
2215
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
+
2216
2228
  let completedResponse = null;
2217
2229
  const renderer = createResponsesStream((e, d) => {
2218
2230
  tapWsEvent(halfTap, e, d);
@@ -2220,7 +2232,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
2220
2232
  if (socket.writable) {
2221
2233
  socket.write(encodeWsFrame(JSON.stringify(d)));
2222
2234
  }
2223
- }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir) });
2235
+ }, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabFor(ir, outFormat) });
2224
2236
 
2225
2237
  renderer.start();
2226
2238
  const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
package/shim.mjs CHANGED
@@ -185,6 +185,7 @@ if tier_is_stale "$ANTHROPIC_DEFAULT_FABLE_MODEL" fable; then unset ANTHROPIC_DE
185
185
  [ "\${CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT:-}" = "1" ] && unset CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
186
186
  [ "\${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-}" = "900000" ] && unset CLAUDE_CODE_AUTO_COMPACT_WINDOW
187
187
  [ "\${OPENAI_MAX_CONTEXT_TOKENS:-}" = "1000000" ] && unset OPENAI_MAX_CONTEXT_TOKENS
188
+ [ "\$TOOL_ACTIVE" != "1" ] && [ "\${CLAUDE_CODE_TETHER_LIVE:-}" = "0" ] && unset CLAUDE_CODE_TETHER_LIVE
188
189
 
189
190
  # LLM_SWITCHER_CODEX_* was always a shim input, never the user's. Every OTHER LLM_SWITCHER_*
190
191
  # name is a user setting and is kept.
@@ -301,6 +302,7 @@ if defined ANTHROPIC_DEFAULT_FABLE_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_FABL
301
302
  if "%CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT%"=="1" set "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT="
302
303
  if "%CLAUDE_CODE_AUTO_COMPACT_WINDOW%"=="900000" set "CLAUDE_CODE_AUTO_COMPACT_WINDOW="
303
304
  if "%OPENAI_MAX_CONTEXT_TOKENS%"=="1000000" set "OPENAI_MAX_CONTEXT_TOKENS="
305
+ if not "%TOOL_ACTIVE%"=="1" if "%CLAUDE_CODE_TETHER_LIVE%"=="0" set "CLAUDE_CODE_TETHER_LIVE="
304
306
  for /f "tokens=1 delims==" %%V in ('set LLM_SWITCHER_CODEX_ 2^>nul ^| findstr /b /c:"LLM_SWITCHER_CODEX_"') do set "%%V="
305
307
  ${caBlock}
306
308
 
package/state.mjs CHANGED
@@ -1365,7 +1365,8 @@ export function computeLaunchState(cfg, port) {
1365
1365
 
1366
1366
  // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
1367
1367
  // because only then does it know whether the user brought a CA of their own (R2).
1368
- if (claude) state.envClaude = proxyPairs();
1368
+ // When Claude route is ON, disable tether (message threads) so Claude Code operates stateless.
1369
+ if (claude) state.envClaude = [...proxyPairs(), ['CLAUDE_CODE_TETHER_LIVE', '0']];
1369
1370
  // Codex builds its TLS client from CODEX_CA_CERTIFICATE the moment it starts, and a path it cannot
1370
1371
  // read is fatal: it dies before its first request, and its long-lived daemon then fails its own
1371
1372
  // update with "Failed to read CA certificate file". So the CA is only offered when it is really
@@ -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
+ });