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.
- package/CHANGELOG.md +5 -0
- package/compaction.mjs +72 -0
- package/formats.mjs +107 -35
- 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/proxy.mjs +52 -51
- 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/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/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
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
|
|
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' });
|