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.
- package/CHANGELOG.md +10 -0
- package/compaction.mjs +72 -0
- package/formats.mjs +112 -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 +69 -57
- package/shim.mjs +2 -0
- package/state.mjs +2 -1
- 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/state.test.mjs +6 -1
- package/tests/tool-repair.test.mjs +228 -2
- package/tests/update.test.mjs +2 -2
- package/tests/upstream-stream.test.mjs +30 -0
- package/toolvocab.mjs +101 -7
- 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 } 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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|