backend-switchboard 0.1.1__py3-none-any.whl

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 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from backend_switchboard._router import main
2
+
3
+ main()
@@ -0,0 +1,216 @@
1
+ """@asksage: translate Anthropic Messages requests to AskSage's /query API, in process (stdlib only).
2
+
3
+ AskSage takes a message history of {"user": "me"|"gpt", "message": text} turns plus an OpenAI-style `tools` list, and
4
+ returns the whole answer at once (`message`, `tool_calls`). It has no slot for tool results, so tool calls and their
5
+ results travel through the history as text turns; the model still sees the real `tools` list.
6
+ """
7
+ import http.client
8
+ import json
9
+ import os
10
+ import ssl
11
+ import time
12
+ import uuid
13
+ from urllib.parse import urlsplit
14
+
15
+
16
+ def _text(content):
17
+ """Flatten a tool_result's content (string or blocks) to text."""
18
+ if isinstance(content, str):
19
+ return content
20
+ return "\n".join(b.get("text", "[%s omitted]" % b.get("type")) if isinstance(b, dict) else str(b)
21
+ for b in content or [])
22
+
23
+
24
+ def _render(blk):
25
+ if isinstance(blk, str):
26
+ return blk
27
+ t = blk.get("type")
28
+ if t == "text":
29
+ return blk.get("text", "")
30
+ if t == "tool_use":
31
+ return "(Earlier in this conversation I used the %s tool with input %s.)" % (blk.get("name"), json.dumps(blk.get("input", {})))
32
+ if t == "tool_result":
33
+ err = " (error)" if blk.get("is_error") else ""
34
+ return "(Result of that tool call%s:)\n%s" % (err, _text(blk.get("content")))
35
+ if t in ("thinking", "redacted_thinking"):
36
+ return ""
37
+ return "[%s omitted]" % t
38
+
39
+
40
+ def to_query(obj):
41
+ """Anthropic request body -> AskSage /query payload."""
42
+ system = obj.get("system")
43
+ if isinstance(system, list):
44
+ system = "\n\n".join(b.get("text", "") for b in system if isinstance(b, dict))
45
+ turns = []
46
+ for m in obj.get("messages", []):
47
+ content = m.get("content")
48
+ text = "\n\n".join(x for x in (_render(b) for b in content) if x) if isinstance(content, list) else content or ""
49
+ who = "gpt" if m.get("role") == "assistant" else "me"
50
+ if turns and turns[-1]["user"] == who:
51
+ turns[-1]["message"] += "\n\n" + text
52
+ else:
53
+ turns.append({"user": who, "message": text})
54
+ q = {"message": turns or "", "model": obj["model"], "persona": "default", "dataset": "none", "live": 0,
55
+ "limit_references": 0}
56
+ if system:
57
+ q["system_prompt"] = system
58
+ if obj.get("temperature") is not None:
59
+ q["temperature"] = obj["temperature"]
60
+ tools = [{"type": "function", "function": {"name": t["name"], "description": t.get("description", ""),
61
+ "parameters": t.get("input_schema", {"type": "object"})}}
62
+ for t in obj.get("tools") or [] if isinstance(t, dict) and t.get("name")]
63
+ if tools: # a JSON list; a JSON-encoded string is silently ignored by most models
64
+ q["tools"] = tools
65
+ choice = (obj.get("tool_choice") or {}).get("type")
66
+ if choice in ("any", "tool"):
67
+ q["tool_choice"] = "required"
68
+ elif choice == "none":
69
+ q["tool_choice"] = "none"
70
+ return q
71
+
72
+
73
+ def _tokens(x):
74
+ return max(1, len(x if isinstance(x, str) else json.dumps(x)) // 4)
75
+
76
+
77
+ def _mimicked_calls(text, names):
78
+ """Models sometimes copy the history's "[tool_use NAME id=..] {json}" notation instead of making a real call.
79
+ Recover those as tool_use blocks (known tool names only); return (remaining text, blocks)."""
80
+ dec, blocks, out, i = json.JSONDecoder(), [], [], 0
81
+ while True:
82
+ j = text.find("[tool_use ", i)
83
+ k = text.find("]", j) if j >= 0 else -1
84
+ if k < 0:
85
+ out.append(text[i:])
86
+ break
87
+ head = text[j + 10:k].split()
88
+ name = (head[0] if head else "").split("functions.")[-1]
89
+ if name not in names:
90
+ out.append(text[i:k + 1])
91
+ i = k + 1
92
+ continue
93
+ args, end = {}, k + 1
94
+ try:
95
+ rest = text[k + 1:]
96
+ val, n = dec.raw_decode(rest.lstrip())
97
+ if isinstance(val, dict):
98
+ args, end = val, k + 1 + (len(rest) - len(rest.lstrip())) + n
99
+ except ValueError:
100
+ pass
101
+ out.append(text[i:j])
102
+ blocks.append({"type": "tool_use", "id": "toolu_" + uuid.uuid4().hex[:24], "name": name, "input": args})
103
+ i = end
104
+ return "".join(out).strip(), blocks
105
+
106
+
107
+ def to_message(d, req, q):
108
+ """AskSage /query response -> (Anthropic message dict, None) or (None, (status, type, message))."""
109
+ err = d.get("error")
110
+ if isinstance(err, dict):
111
+ code = err.get("code", "")
112
+ status = {"model_not_permitted": 403}.get(code, err.get("http_status") or 502)
113
+ return None, (status, "permission_error" if status == 403 else "api_error", d.get("message") or code)
114
+ content = []
115
+ text = (d.get("message") or "").strip()
116
+ if text:
117
+ content.append({"type": "text", "text": text})
118
+ for c in d.get("tool_calls_unified") or d.get("tool_calls") or []:
119
+ # OpenAI-shaped ({function: {name, arguments}}, arguments a JSON string or dict) or Anthropic-shaped ({name, input})
120
+ fn = c.get("function") or c
121
+ args = fn.get("arguments", fn.get("input")) or {}
122
+ if isinstance(args, str):
123
+ try:
124
+ args = json.loads(args)
125
+ except ValueError:
126
+ args = {}
127
+ if fn.get("name"):
128
+ content.append({"type": "tool_use", "id": str(c.get("id") or "toolu_" + uuid.uuid4().hex[:24]),
129
+ "name": fn["name"], "input": args if isinstance(args, dict) else {}})
130
+ names = {t["name"] for t in req.get("tools") or [] if isinstance(t, dict) and t.get("name")}
131
+ if "[tool_use " in text and not any(b["type"] == "tool_use" for b in content):
132
+ text, found = _mimicked_calls(text, names)
133
+ content = ([{"type": "text", "text": text}] if text else []) + found
134
+ tool = any(b["type"] == "tool_use" for b in content)
135
+ return {"id": "msg_" + uuid.uuid4().hex[:24], "type": "message", "role": "assistant", "model": req["model"],
136
+ "content": content or [{"type": "text", "text": ""}], "stop_reason": "tool_use" if tool else "end_turn",
137
+ "stop_sequence": None,
138
+ "usage": {"input_tokens": _tokens(q["message"]) + _tokens(q.get("tools", [])) + _tokens(q.get("system_prompt", "")),
139
+ "output_tokens": _tokens(content)}}, None
140
+
141
+
142
+ def sse(msg):
143
+ """Whole message -> Anthropic SSE event stream."""
144
+ def ev(name, data):
145
+ return ("event: %s\ndata: %s\n\n" % (name, json.dumps(dict(data, type=name)))).encode()
146
+ out = [ev("message_start", {"message": dict(msg, content=[], stop_reason=None,
147
+ usage={"input_tokens": msg["usage"]["input_tokens"], "output_tokens": 1})})]
148
+ for i, b in enumerate(msg["content"]):
149
+ if b["type"] == "tool_use":
150
+ out.append(ev("content_block_start", {"index": i, "content_block": dict(b, input={})}))
151
+ out.append(ev("content_block_delta", {"index": i, "delta": {"type": "input_json_delta",
152
+ "partial_json": json.dumps(b["input"])}}))
153
+ else:
154
+ out.append(ev("content_block_start", {"index": i, "content_block": {"type": "text", "text": ""}}))
155
+ out.append(ev("content_block_delta", {"index": i, "delta": {"type": "text_delta", "text": b["text"]}}))
156
+ out.append(ev("content_block_stop", {"index": i}))
157
+ out.append(ev("message_delta", {"delta": {"stop_reason": msg["stop_reason"], "stop_sequence": None},
158
+ "usage": {"output_tokens": msg["usage"]["output_tokens"]}}))
159
+ out.append(ev("message_stop", {}))
160
+ return b"".join(out)
161
+
162
+
163
+ def _query(cfg, q, key):
164
+ u = urlsplit(cfg["asksage_url"])
165
+ if u.scheme == "https":
166
+ ctx = ssl.create_default_context()
167
+ cert = cfg.get("asksage_cert")
168
+ if cert:
169
+ ctx.load_verify_locations(os.path.expanduser(cert))
170
+ conn = http.client.HTTPSConnection(u.hostname, u.port or 443, timeout=600, context=ctx)
171
+ else:
172
+ conn = http.client.HTTPConnection(u.hostname, u.port or 80, timeout=600)
173
+ try:
174
+ conn.request("POST", u.path.rstrip("/") + "/query", json.dumps(q),
175
+ {"x-access-tokens": key, "content-type": "application/json"})
176
+ r = conn.getresponse()
177
+ raw = r.read()
178
+ return r.status, raw
179
+ finally:
180
+ conn.close()
181
+
182
+
183
+ def handle(h, cfg, obj):
184
+ """Serve one /v1/messages (or count_tokens) request on handler `h`; return the HTTP status sent."""
185
+ def send(status, body, ctype="application/json"):
186
+ h.send_response(status)
187
+ h.send_header("content-type", ctype)
188
+ h.send_header("content-length", str(len(body)))
189
+ h.send_header("connection", "close")
190
+ h.end_headers()
191
+ h.wfile.write(body)
192
+ h.close_connection = True
193
+ return status
194
+
195
+ def fail(status, etype, message):
196
+ return send(status, json.dumps({"type": "error", "error": {"type": etype, "message": message}}).encode())
197
+
198
+ q = to_query(obj)
199
+ if h.path.split("?")[0].endswith("/count_tokens"):
200
+ return send(200, json.dumps({"input_tokens": _tokens(q["message"]) + _tokens(q.get("tools", []))}).encode())
201
+ key = os.environ.get("ASKSAGE_API_KEY")
202
+ if not key:
203
+ return fail(401, "authentication_error", "ASKSAGE_API_KEY is not set in the router's environment (restart it after exporting)")
204
+ try:
205
+ status, raw = _query(cfg, q, key)
206
+ d = json.loads(raw)
207
+ except (OSError, ValueError) as e:
208
+ return fail(502, "api_error", "AskSage request failed: %s" % e)
209
+ if status != 200:
210
+ return fail(status, "api_error", str(d.get("message") if isinstance(d, dict) else raw[:300]))
211
+ msg, err = to_message(d, obj, q)
212
+ if err:
213
+ return fail(*err)
214
+ if obj.get("stream"):
215
+ return send(200, sse(msg), "text/event-stream")
216
+ return send(200, json.dumps(msg).encode())
@@ -0,0 +1,230 @@
1
+ """@copilot: GitHub Copilot's API (https://api.githubcopilot.com), authenticated with your `gh` login.
2
+
3
+ Copilot lists the endpoints each model supports. Claude models serve the Anthropic Messages API natively and are
4
+ forwarded by the router like any other native backend. Models that only offer OpenAI's /responses API (gpt-*, grok-*,
5
+ mai-*) are translated here, in process (stdlib only). Answers are requested whole and streaming is emulated.
6
+ Models that only offer /chat/completions (gemini-*) are not supported.
7
+ """
8
+ import http.client
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import ssl
14
+ import subprocess
15
+ import threading
16
+ import time
17
+ from urllib.parse import urlsplit
18
+
19
+ from ._asksage import _text, _tokens, sse
20
+
21
+ HEADERS = {"user-agent": "GitHubCopilot/1.0.105", "copilot-integration-id": "copilot-developer-cli",
22
+ "x-initiator": "user"}
23
+ _tok = {"lock": threading.Lock(), "token": None, "at": 0.0}
24
+ _models = {"lock": threading.Lock(), "map": None, "at": 0.0}
25
+
26
+
27
+ def token():
28
+ """COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN if set, else `gh auth token` (cached 2 min)."""
29
+ for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
30
+ if os.environ.get(var):
31
+ return os.environ[var]
32
+ with _tok["lock"]:
33
+ if _tok["token"] and time.time() - _tok["at"] < 120:
34
+ return _tok["token"]
35
+ exe = shutil.which("gh") or os.path.join(os.path.expanduser("~"), ".local/bin/gh")
36
+ try:
37
+ out = subprocess.run([exe, "auth", "token"], capture_output=True, text=True, timeout=30)
38
+ except (OSError, subprocess.TimeoutExpired):
39
+ return None
40
+ _tok["token"], _tok["at"] = out.stdout.strip() or None, time.time()
41
+ return _tok["token"]
42
+
43
+
44
+ def upstream_name(model):
45
+ """Claude Code says claude-opus-5-5; Copilot says claude-opus-5.5."""
46
+ if model.startswith("claude-"):
47
+ return re.sub(r"-(\d{1,2})-(\d{1,2})(?=-|$)", r"-\1.\2", model)
48
+ return model
49
+
50
+
51
+ def _conn(cfg, timeout):
52
+ u = urlsplit(cfg["copilot_url"])
53
+ if u.scheme == "https":
54
+ return u, http.client.HTTPSConnection(u.hostname, u.port or 443, timeout=timeout, context=ssl.create_default_context())
55
+ return u, http.client.HTTPConnection(u.hostname, u.port or 80, timeout=timeout)
56
+
57
+
58
+ def _get(cfg, path, tok):
59
+ u, conn = _conn(cfg, 30)
60
+ try:
61
+ conn.request("GET", u.path.rstrip("/") + path, headers=dict(HEADERS, authorization="Bearer " + tok))
62
+ r = conn.getresponse()
63
+ return r.status, r.read()
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def _fetch_models(cfg, tok):
69
+ status, raw = _get(cfg, "/models", tok)
70
+ if status != 200:
71
+ raise ValueError("HTTP %s" % status)
72
+ m = {}
73
+ for d in json.loads(raw)["data"]:
74
+ eps = d.get("supported_endpoints") or []
75
+ m[d["id"]] = ("messages" if "/v1/messages" in eps else "responses" if "/responses" in eps
76
+ else "chat" if "/chat/completions" in eps else None)
77
+ return m
78
+
79
+
80
+ def endpoint(cfg, model, tok):
81
+ """'messages', 'responses' or 'chat' for this model, from Copilot's model list (cached 10 min; a failed fetch keeps
82
+ the old list and is retried after 1 min). The fetch runs outside the lock so a slow /models can't stall requests."""
83
+ with _models["lock"]:
84
+ stale = _models["map"] is None or time.time() > _models["at"]
85
+ if stale:
86
+ _models["at"] = time.time() + 60 # claim the refresh; other requests use the old map meanwhile
87
+ if stale:
88
+ try:
89
+ m = _fetch_models(cfg, tok)
90
+ with _models["lock"]:
91
+ _models["map"], _models["at"] = m, time.time() + 600
92
+ except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException):
93
+ pass
94
+ hit = (_models["map"] or {}).get(model)
95
+ return hit or ("messages" if model.startswith("claude-") else "responses")
96
+
97
+
98
+ def _image(b):
99
+ s = b.get("source") or {}
100
+ url = s.get("url") if s.get("type") == "url" else "data:%s;base64,%s" % (s.get("media_type"), s.get("data"))
101
+ return {"type": "input_image", "image_url": url}
102
+
103
+
104
+ def to_responses(obj):
105
+ """Anthropic request body -> OpenAI Responses payload."""
106
+ system = obj.get("system")
107
+ if isinstance(system, list):
108
+ system = "\n\n".join(b.get("text", "") for b in system if isinstance(b, dict))
109
+ items = []
110
+ for m in obj.get("messages", []):
111
+ content = m.get("content")
112
+ asst = m.get("role") == "assistant"
113
+ tt = "output_text" if asst else "input_text"
114
+ if isinstance(content, str):
115
+ content = [{"type": "text", "text": content}]
116
+ parts = []
117
+
118
+ def flush():
119
+ if parts:
120
+ items.append({"role": "assistant" if asst else "user", "content": list(parts)})
121
+ del parts[:]
122
+ for b in content or []:
123
+ t = b.get("type")
124
+ if t == "text" and b.get("text"):
125
+ parts.append({"type": tt, "text": b["text"]})
126
+ elif t == "image" and not asst:
127
+ parts.append(_image(b))
128
+ elif t == "tool_use":
129
+ flush()
130
+ items.append({"type": "function_call", "call_id": b["id"], "name": b["name"],
131
+ "arguments": json.dumps(b.get("input", {}))})
132
+ elif t == "tool_result":
133
+ flush()
134
+ out = _text(b.get("content"))
135
+ items.append({"type": "function_call_output", "call_id": b["tool_use_id"],
136
+ "output": ("Error: " + out) if b.get("is_error") else out})
137
+ flush()
138
+ r = {"model": obj["model"], "input": items, "stream": False}
139
+ if system:
140
+ r["instructions"] = system
141
+ if obj.get("max_tokens"):
142
+ r["max_output_tokens"] = obj["max_tokens"]
143
+ tools = [{"type": "function", "name": t["name"], "description": t.get("description", ""),
144
+ "parameters": t.get("input_schema", {"type": "object"})}
145
+ for t in obj.get("tools") or [] if isinstance(t, dict) and t.get("name")]
146
+ if tools:
147
+ r["tools"] = tools
148
+ tc = obj.get("tool_choice") or {}
149
+ if tc.get("type") == "any":
150
+ r["tool_choice"] = "required"
151
+ elif tc.get("type") == "tool":
152
+ r["tool_choice"] = {"type": "function", "name": tc.get("name")}
153
+ elif tc.get("type") == "none":
154
+ r["tool_choice"] = "none"
155
+ return r
156
+
157
+
158
+ def to_message(d, req):
159
+ """Responses result -> (Anthropic message dict, None) or (None, (status, type, message))."""
160
+ if isinstance(d.get("error"), dict):
161
+ e = d["error"]
162
+ return None, (502, "api_error", e.get("message") or e.get("code") or "upstream error")
163
+ content = []
164
+ for it in d.get("output") or []:
165
+ if it.get("type") == "message":
166
+ text = "".join(c.get("text", "") for c in it.get("content") or [] if c.get("type") == "output_text")
167
+ if text.strip():
168
+ content.append({"type": "text", "text": text})
169
+ elif it.get("type") == "function_call":
170
+ try:
171
+ args = json.loads(it.get("arguments") or "{}")
172
+ except ValueError:
173
+ args = {}
174
+ content.append({"type": "tool_use", "id": it.get("call_id") or it.get("id"), "name": it.get("name"),
175
+ "input": args if isinstance(args, dict) else {}})
176
+ tool = any(b["type"] == "tool_use" for b in content)
177
+ cut = d.get("status") == "incomplete"
178
+ refused = cut and (d.get("incomplete_details") or {}).get("reason") == "content_filter"
179
+ u = d.get("usage") or {}
180
+ return {"id": "msg_" + (d.get("id") or "x").replace("resp_", "")[:24], "type": "message", "role": "assistant",
181
+ "model": req["model"], "content": content or [{"type": "text", "text": ""}],
182
+ "stop_reason": "tool_use" if tool else "refusal" if refused else "max_tokens" if cut else "end_turn", "stop_sequence": None,
183
+ "usage": {"input_tokens": u.get("input_tokens") or _tokens(req.get("messages", [])),
184
+ "output_tokens": u.get("output_tokens") or _tokens(content)}}, None
185
+
186
+
187
+ def handle(h, cfg, obj, tok, model, kind):
188
+ """Serve one /v1/messages (or count_tokens) request on handler `h` via /responses; return the status sent.
189
+ `kind` is endpoint()'s answer for the model; only non-native ('responses'/'chat') models reach here."""
190
+ def send(status, body, ctype="application/json"):
191
+ h.send_response(status)
192
+ h.send_header("content-type", ctype)
193
+ h.send_header("content-length", str(len(body)))
194
+ h.send_header("connection", "close")
195
+ h.end_headers()
196
+ h.wfile.write(body)
197
+ h.close_connection = True
198
+ return status
199
+
200
+ def fail(status, etype, message):
201
+ return send(status, json.dumps({"type": "error", "error": {"type": etype, "message": message}}).encode())
202
+
203
+ req = to_responses(obj)
204
+ if h.path.split("?")[0].endswith("/count_tokens"):
205
+ return send(200, json.dumps({"input_tokens": _tokens(req["input"]) + _tokens(req.get("tools", []))}).encode())
206
+ if kind == "chat":
207
+ return fail(400, "invalid_request_error", "%s only offers /chat/completions on Copilot, which @copilot does not support" % model)
208
+ u, conn = _conn(cfg, 900)
209
+ try:
210
+ conn.request("POST", u.path.rstrip("/") + "/responses", json.dumps(req),
211
+ dict(HEADERS, authorization="Bearer " + tok, **{"content-type": "application/json"}))
212
+ r = conn.getresponse()
213
+ raw = r.read()
214
+ d = json.loads(raw)
215
+ except (OSError, ValueError, http.client.HTTPException) as e:
216
+ return fail(502, "api_error", "Copilot request failed: %s" % e)
217
+ finally:
218
+ conn.close()
219
+ if r.status != 200:
220
+ e = d.get("error") if isinstance(d, dict) else None
221
+ msg = e.get("message") if isinstance(e, dict) else raw[:300].decode("utf8", "replace")
222
+ etype = {400: "invalid_request_error", 401: "authentication_error", 403: "permission_error", 404: "not_found_error",
223
+ 413: "request_too_large", 429: "rate_limit_error", 503: "overloaded_error"}.get(r.status, "api_error")
224
+ return fail(r.status, etype, str(msg))
225
+ msg, err = to_message(d, obj)
226
+ if err:
227
+ return fail(*err)
228
+ if obj.get("stream"):
229
+ return send(200, sse(msg), "text/event-stream")
230
+ return send(200, json.dumps(msg).encode())
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env python3
2
+ """backend-switchboard: one local Anthropic-API endpoint for Claude Code, routed by /model suffix.
3
+
4
+ claude-opus-5-5 -> Anthropic (your subscription; client headers pass through untouched)
5
+ claude-opus-5-5@google@mag -> AMSC MAG (router tag @mag is stripped; "@google" is MAG's own suffix)
6
+ claude-opus-5-5@argo -> Argo via argo-shim
7
+ beam@alcf / muse@alcf -> ALCF Minerva (serves the Anthropic Messages API natively; Bearer token)
8
+ gpt-5.4@asksage -> AskSage /query (translated in-process; needs ASKSAGE_API_KEY)
9
+ claude-opus-5-5@copilot -> GitHub Copilot (Claude: native; gpt-*/grok-*: /responses, translated; uses `gh auth token`)
10
+
11
+ Standard library only (3.8+). Upstream credentials are read per request and never logged.
12
+ """
13
+ import argparse
14
+ import hmac
15
+ import http.client
16
+ import json
17
+ import os
18
+ import secrets
19
+ import shlex
20
+ import shutil
21
+ import signal
22
+ import ssl
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ import time
27
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
28
+ from urllib.parse import urlsplit
29
+
30
+ from . import _asksage as asksage
31
+ from . import _copilot as copilot
32
+
33
+ HOME = os.path.expanduser("~")
34
+ DIR = os.environ.get("BACKEND_SWITCHBOARD_HOME", os.path.join(HOME, ".backend-switchboard"))
35
+ BACKENDS = ("anthropic", "argo", "mag", "alcf", "asksage", "copilot")
36
+ HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers",
37
+ "transfer-encoding", "upgrade", "host", "content-length"}
38
+ SOFT_ERRORS = (b"ACCESS REVOKED", b"IMPORTANT USAGE NOTICE")
39
+
40
+ DEFAULTS = {
41
+ "port": 8790,
42
+ "tool_search": "true",
43
+ "anthropic_url": "https://api.anthropic.com",
44
+ "mag_url": "https://mag.genesis.americansciencecloud.org",
45
+ "argo_settings": os.path.join(HOME, ".claude", "settings.json"),
46
+ "argo_url": None,
47
+ "alcf_url": "https://inference-api.alcf.anl.gov/resource_server/minerva/api",
48
+ "asksage_url": "https://api.asksage.anl.gov/server",
49
+ "asksage_cert": None, # extra CA bundle (ANL's AskSage cert), e.g. ~/.config/asksage_proxy/asksage_anl_gov.pem
50
+ "copilot_url": "https://api.githubcopilot.com",
51
+ "aliases": {"alcf": {"beam": "beam-1.0-bf16", "muse": "muse-spark-bf16"}},
52
+ }
53
+
54
+
55
+ def load_config():
56
+ cfg = dict(DEFAULTS)
57
+ try:
58
+ with open(os.path.join(DIR, "config.json")) as f:
59
+ cfg.update(json.load(f))
60
+ except FileNotFoundError:
61
+ pass
62
+ return cfg
63
+
64
+
65
+ def read_key():
66
+ with open(os.path.join(DIR, "key")) as f:
67
+ return f.read().strip()
68
+
69
+
70
+ def split_model(model):
71
+ """'name@mag' -> ('name', 'mag'); only the LAST @tag, and only if it is a router backend."""
72
+ base, sep, tag = model.rpartition("@")
73
+ if sep and tag in BACKENDS:
74
+ return base, tag
75
+ return model, None
76
+
77
+
78
+ class RouteError(Exception):
79
+ def __init__(self, status, message):
80
+ Exception.__init__(self, message)
81
+ self.status, self.message = status, message
82
+
83
+
84
+ _cache = {}
85
+
86
+
87
+ def cached_file(path, parse):
88
+ """Re-parse a file only when its mtime changes (tokens rotate; settings files get rewritten)."""
89
+ try:
90
+ m = os.stat(path).st_mtime
91
+ except OSError:
92
+ return None
93
+ key = (path, parse.__name__)
94
+ hit = _cache.get(key)
95
+ if hit and hit[0] == m:
96
+ return hit[1]
97
+ try:
98
+ val = parse(path)
99
+ except Exception:
100
+ val = None
101
+ _cache[key] = (m, val)
102
+ return val
103
+
104
+
105
+ def _argo_token(path):
106
+ helper = json.load(open(path)).get("apiKeyHelper", "")
107
+ words = shlex.split(helper)
108
+ return words[-1] if words else None
109
+
110
+
111
+ def _argo_base(path):
112
+ url = json.load(open(path)).get("env", {}).get("ANTHROPIC_BASE_URL", "")
113
+ return url if urlsplit(url).hostname in ("127.0.0.1", "localhost") else None
114
+
115
+
116
+ def mag_token():
117
+ return os.environ.get("AMSC_TOKEN")
118
+
119
+
120
+ _alcf = {"lock": threading.Lock(), "token": None, "at": 0.0}
121
+
122
+
123
+ def alcf_token():
124
+ """ALCF inference token: ALCF_INFERENCE_TOKEN if set, else `alcf-tokens get-token inference` (cached 2 min)."""
125
+ tok = os.environ.get("ALCF_INFERENCE_TOKEN")
126
+ if tok:
127
+ return tok
128
+ with _alcf["lock"]:
129
+ if _alcf["token"] and time.time() - _alcf["at"] < 120:
130
+ return _alcf["token"]
131
+ exe = shutil.which("alcf-tokens") or os.path.join(HOME, ".local/bin/alcf-tokens")
132
+ try:
133
+ out = subprocess.run([exe, "get-token", "inference"], capture_output=True, text=True, timeout=30)
134
+ except (OSError, subprocess.TimeoutExpired):
135
+ return None
136
+ _alcf["token"], _alcf["at"] = out.stdout.strip() or None, time.time()
137
+ return _alcf["token"]
138
+
139
+
140
+ def resolve(cfg, model):
141
+ """Return (base_url, extra_headers, drop_client_auth, upstream_model, backend)."""
142
+ base, tag = split_model(model)
143
+ if tag is None:
144
+ if "@" in model:
145
+ raise RouteError(400, "ambiguous model %r: add a router tag, e.g. %s@mag or %s@argo (tags: %s)"
146
+ % (model, model, model, ", ".join(BACKENDS)))
147
+ return cfg["anthropic_url"], {}, False, model, "anthropic"
148
+ if tag == "anthropic":
149
+ return cfg["anthropic_url"], {}, False, base, tag
150
+ if tag == "mag":
151
+ tok = mag_token()
152
+ if not tok:
153
+ raise RouteError(401, "AMSC_TOKEN is not set in the router's environment (restart it after exporting)")
154
+ return cfg["mag_url"], {"authorization": "Bearer " + tok}, True, base, tag
155
+ if tag == "argo":
156
+ tok = cached_file(cfg["argo_settings"], _argo_token)
157
+ url = cfg["argo_url"] or cached_file(cfg["argo_settings"], _argo_base)
158
+ if not tok or not url:
159
+ raise RouteError(502, "cannot read argo-shim token/base URL from %s" % cfg["argo_settings"])
160
+ return url, {"x-api-key": tok}, True, base, tag
161
+ if tag == "asksage":
162
+ return cfg["asksage_url"], {}, True, base, tag
163
+ if tag == "copilot":
164
+ tok = copilot.token()
165
+ if not tok:
166
+ raise RouteError(401, "no GitHub token; run in your own terminal: gh auth login (or set COPILOT_GITHUB_TOKEN)")
167
+ return (cfg["copilot_url"], dict(copilot.HEADERS, authorization="Bearer " + tok), True,
168
+ cfg["aliases"].get("copilot", {}).get(base, copilot.upstream_name(base)), tag)
169
+ tok = alcf_token()
170
+ if not tok:
171
+ raise RouteError(401, "no ALCF token; run in your own terminal: alcf-tokens login inference")
172
+ return cfg["alcf_url"], {"authorization": "Bearer " + tok}, True, cfg["aliases"]["alcf"].get(base, base), tag
173
+
174
+
175
+ BLOCK_TYPES = {"text", "image", "document", "tool_use", "tool_result", "thinking", "redacted_thinking",
176
+ "server_tool_use", "web_search_tool_result", "search_result", "container_upload"}
177
+
178
+
179
+ def strip_client_extensions(obj):
180
+ """Drop Claude Code's Anthropic-only extras that Vertex-backed Argo rejects with a 400: message-level keys other
181
+ than role/content (e.g. output_config) and content blocks of unknown type (e.g. tool_addition).
182
+ Returns True if anything changed."""
183
+ changed = False
184
+ for msg in obj.get("messages", []):
185
+ if not isinstance(msg, dict):
186
+ continue
187
+ for k in [k for k in msg if k not in ("role", "content")]:
188
+ del msg[k]
189
+ changed = True
190
+ content = msg.get("content")
191
+ if isinstance(content, list):
192
+ kept = [b for b in content if not isinstance(b, dict) or b.get("type") in BLOCK_TYPES]
193
+ if len(kept) != len(content):
194
+ msg["content"] = kept
195
+ changed = True
196
+ return changed
197
+
198
+
199
+ def flatten_tool_search(obj):
200
+ """Make Claude Code's deferred-tool search safe for backends that lack Anthropic's tool_reference blocks.
201
+
202
+ The client already adds a loaded tool's full schema to `tools` on the next request, so it is enough to drop
203
+ the defer_loading placeholder and turn each tool_reference into a text result. Returns True if anything changed.
204
+ """
205
+ changed = False
206
+ tools = obj.get("tools")
207
+ if isinstance(tools, list):
208
+ kept = [t for t in tools if not (isinstance(t, dict) and t.get("defer_loading"))]
209
+ changed = len(kept) != len(tools)
210
+ obj["tools"] = kept
211
+ for msg in obj.get("messages", []):
212
+ content = msg.get("content")
213
+ if not isinstance(content, list):
214
+ continue
215
+ for blk in content:
216
+ inner = blk.get("content") if isinstance(blk, dict) and blk.get("type") == "tool_result" else None
217
+ if not isinstance(inner, list):
218
+ continue
219
+ for i, b in enumerate(inner):
220
+ if isinstance(b, dict) and b.get("type") == "tool_reference":
221
+ inner[i] = {"type": "text", "text": "Tool loaded: %s. Its schema is now in your tool list; "
222
+ "call it directly." % b.get("tool_name")}
223
+ changed = True
224
+ return changed
225
+
226
+
227
+ class Handler(BaseHTTPRequestHandler):
228
+ cfg = key = None
229
+ server_version = "backend-switchboard"
230
+
231
+ def log_message(self, *a):
232
+ pass
233
+
234
+ def _err(self, status, message):
235
+ body = json.dumps({"type": "error", "error": {"type": "invalid_request_error" if status < 500 else "api_error",
236
+ "message": message}}).encode()
237
+ self.send_response(status)
238
+ self.send_header("content-type", "application/json")
239
+ self.send_header("content-length", str(len(body)))
240
+ self.send_header("connection", "close")
241
+ self.end_headers()
242
+ self.wfile.write(body)
243
+
244
+ def do_HEAD(self):
245
+ self.send_response(200)
246
+ self.send_header("content-length", "0")
247
+ self.end_headers()
248
+
249
+ def do_GET(self):
250
+ if self.path == "/health":
251
+ return self._plain(b'{"ok":true}')
252
+ self._proxy()
253
+
254
+ def do_POST(self):
255
+ self._proxy()
256
+
257
+ def _plain(self, body):
258
+ self.send_response(200)
259
+ self.send_header("content-type", "application/json")
260
+ self.send_header("content-length", str(len(body)))
261
+ self.end_headers()
262
+ self.wfile.write(body)
263
+
264
+ def _proxy(self):
265
+ t0 = time.time()
266
+ if not hmac.compare_digest(self.headers.get("x-router-key", ""), self.key):
267
+ return self._err(401, "missing or wrong x-router-key")
268
+ n = int(self.headers.get("content-length") or 0)
269
+ raw = self.rfile.read(n) if n else b""
270
+ model, backend = "-", "?"
271
+ try:
272
+ body = raw
273
+ if raw and self.path.split("?")[0].startswith("/v1/messages"):
274
+ try:
275
+ obj = json.loads(raw)
276
+ except ValueError:
277
+ obj = None
278
+ if isinstance(obj, dict) and isinstance(obj.get("model"), str):
279
+ model = obj["model"]
280
+ url, extra, drop_auth, up_model, backend = resolve(self.cfg, model) \
281
+ if model != "-" else (self.cfg["anthropic_url"], {}, False, None, "anthropic")
282
+ if model != "-":
283
+ dirty = up_model != model
284
+ obj["model"] = up_model
285
+ if backend != "anthropic":
286
+ dirty = flatten_tool_search(obj) or dirty
287
+ dirty = strip_client_extensions(obj) or dirty
288
+ if dirty:
289
+ body = json.dumps(obj).encode()
290
+ if backend == "asksage":
291
+ status = asksage.handle(self, self.cfg, obj)
292
+ else:
293
+ kind = copilot.endpoint(self.cfg, up_model, extra["authorization"][7:]) if backend == "copilot" else None
294
+ if kind and kind != "messages":
295
+ status = copilot.handle(self, self.cfg, obj, extra["authorization"][7:], up_model, kind)
296
+ else:
297
+ status = self._forward(url, extra, drop_auth, body, backend)
298
+ except RouteError as e:
299
+ status = e.status
300
+ self._err(e.status, e.message)
301
+ except Exception as e:
302
+ status = 502 if isinstance(e, (OSError, http.client.HTTPException)) else 500
303
+ sys.stderr.write("router error: %r\n" % (e,))
304
+ try:
305
+ self._err(status, "router/upstream error: %s" % e)
306
+ except OSError:
307
+ pass
308
+ sys.stderr.write("%s %-40s -> %s %s %.2fs\n" % (time.strftime("%H:%M:%S"), model[:40],
309
+ backend, status, time.time() - t0))
310
+ sys.stderr.flush()
311
+
312
+ def _forward(self, url, extra, drop_auth, body, backend):
313
+ u = urlsplit(url)
314
+ headers = {}
315
+ for k, v in self.headers.items():
316
+ lk = k.lower()
317
+ if lk in HOP or lk == "x-router-key":
318
+ continue
319
+ if drop_auth and lk in ("authorization", "x-api-key"):
320
+ continue
321
+ if backend == "argo" and lk == "accept-encoding":
322
+ continue
323
+ headers[lk] = v
324
+ headers.update(extra)
325
+ if u.scheme == "https":
326
+ conn = http.client.HTTPSConnection(u.hostname, u.port or 443, timeout=900, context=ssl.create_default_context())
327
+ else:
328
+ conn = http.client.HTTPConnection(u.hostname, u.port or 80, timeout=900)
329
+ conn.request(self.command, u.path.rstrip("/") + self.path, body or None, headers)
330
+ resp = conn.getresponse()
331
+ first = b""
332
+ if backend == "argo" and resp.status == 200:
333
+ first = resp.read1(8192)
334
+ if any(s in first[:4000] for s in SOFT_ERRORS):
335
+ self._err(429, "Argo returned a usage/quota notice (HTTP 200 soft error): " + first[:300].decode("utf8", "replace"))
336
+ conn.close()
337
+ return 429
338
+ self.send_response(resp.status, resp.reason)
339
+ for k, v in resp.getheaders():
340
+ if k.lower() not in HOP:
341
+ self.send_header(k, v)
342
+ self.send_header("connection", "close")
343
+ self.end_headers()
344
+ self.close_connection = True
345
+ if first:
346
+ self.wfile.write(first)
347
+ self.wfile.flush()
348
+ while True:
349
+ chunk = resp.read1(8192)
350
+ if not chunk:
351
+ break
352
+ self.wfile.write(chunk)
353
+ self.wfile.flush()
354
+ conn.close()
355
+ return resp.status
356
+
357
+
358
+ def build_server(cfg):
359
+ Handler.cfg, Handler.key = cfg, read_key()
360
+ ThreadingHTTPServer.daemon_threads = True
361
+ return ThreadingHTTPServer(("127.0.0.1", cfg["port"]), Handler)
362
+
363
+
364
+ def healthy(port):
365
+ try:
366
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
367
+ c.request("GET", "/health")
368
+ return c.getresponse().status == 200
369
+ except OSError:
370
+ return False
371
+
372
+
373
+ def cmd_init(cfg, quiet=False):
374
+ os.makedirs(DIR, mode=0o700, exist_ok=True)
375
+ kp = os.path.join(DIR, "key")
376
+ if not os.path.exists(kp):
377
+ fd = os.open(kp, os.O_WRONLY | os.O_CREAT, 0o600)
378
+ with os.fdopen(fd, "w") as f:
379
+ f.write(secrets.token_urlsafe(32) + "\n")
380
+ settings = {"env": {
381
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:%d" % cfg["port"],
382
+ "ANTHROPIC_CUSTOM_HEADERS": "x-router-key: " + read_key(),
383
+ "ENABLE_TOOL_SEARCH": str(cfg["tool_search"]),
384
+ }}
385
+ sp = os.path.join(DIR, "settings.json")
386
+ fd = os.open(sp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
387
+ with os.fdopen(fd, "w") as f:
388
+ json.dump(settings, f, indent=2)
389
+ if not quiet:
390
+ print("wrote", kp, "and", sp)
391
+ print("launch Claude Code with: backend-switchboard run (then /model <name>[@argo|@mag|@alcf|@asksage|@copilot])")
392
+
393
+
394
+ def cmd_ensure(cfg):
395
+ if healthy(cfg["port"]):
396
+ return
397
+ log = open(os.path.join(DIR, "router.log"), "ab")
398
+ p = subprocess.Popen([sys.executable, "-m", "backend_switchboard", "serve"], stdout=log, stderr=log,
399
+ stdin=subprocess.DEVNULL, start_new_session=True)
400
+ with open(os.path.join(DIR, "router.pid"), "w") as f:
401
+ f.write(str(p.pid))
402
+ for _ in range(20):
403
+ if healthy(cfg["port"]):
404
+ return
405
+ time.sleep(0.25)
406
+ sys.exit("backend-switchboard failed to start; see %s/router.log" % DIR)
407
+
408
+
409
+ def cmd_run(cfg, args):
410
+ cmd_init(cfg, quiet=True) # keeps settings.json in sync with config.json (port)
411
+ cmd_ensure(cfg)
412
+ if shutil.which("claude") is None:
413
+ sys.exit("claude not found on PATH; install Claude Code first")
414
+ os.execvp("claude", ["claude", "--settings", os.path.join(DIR, "settings.json")] + args)
415
+
416
+
417
+ def cmd_stop():
418
+ try:
419
+ pid = int(open(os.path.join(DIR, "router.pid")).read())
420
+ os.kill(pid, 15)
421
+ print("stopped", pid)
422
+ except (OSError, ValueError):
423
+ print("not running")
424
+
425
+
426
+ def main():
427
+ if len(sys.argv) > 1 and sys.argv[1] == "run": # everything after "run" belongs to claude
428
+ return cmd_run(load_config(), sys.argv[2:])
429
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
430
+ ap.add_argument("command", choices=["init", "serve", "ensure", "stop"],
431
+ help="run [claude args...] is also accepted: starts the router if needed, then launches claude")
432
+ cmd = ap.parse_args().command
433
+ cfg = load_config()
434
+ if cmd == "init":
435
+ cmd_init(cfg)
436
+ elif cmd == "serve":
437
+ srv = build_server(cfg)
438
+ signal.signal(signal.SIGTERM, lambda *a: sys.exit(0))
439
+ try:
440
+ srv.serve_forever()
441
+ except KeyboardInterrupt:
442
+ pass
443
+ elif cmd == "ensure":
444
+ cmd_ensure(cfg)
445
+ else:
446
+ cmd_stop()
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.5
2
+ Name: backend-switchboard
3
+ Version: 0.1.1
4
+ Summary: Switch Claude Code between your Anthropic subscription, Argo, MAG and ALCF models with /model
5
+ Project-URL: Homepage, https://github.com/n-getty/backend-switchboard
6
+ Project-URL: Repository, https://github.com/n-getty/backend-switchboard
7
+ Project-URL: Issues, https://github.com/n-getty/backend-switchboard/issues
8
+ Author: Neil Getty
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: alcf,anthropic,argo,claude-code,llm-gateway,proxy,router
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Internet :: Proxy Servers
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+
22
+ # backend-switchboard
23
+
24
+ One local endpoint that lets Claude Code switch between several model backends with `/model`, in a single session:
25
+
26
+ | `/model` value | Goes to |
27
+ |---|---|
28
+ | `claude-sonnet-5-5` (no tag) | Anthropic, using your own Claude Code login. Requests are forwarded untouched. |
29
+ | `claude-sonnet-5-5@argo` | [Argo](https://github.com/n-getty/argo-shim) through a running `argo-shim` |
30
+ | `claude-opus-5-5@google@mag` | A LiteLLM-style gateway with Bearer auth (AMSC "MAG"); the tag `@mag` is stripped and `@google` is MAG's own suffix |
31
+ | `beam@alcf`, `muse@alcf` | ALCF inference endpoints, which serve the Anthropic Messages API natively |
32
+ | `google-claude-opus-5@asksage` | AskSage models, translated in-process to AskSage's `/query` API |
33
+ | `claude-opus-5-5@copilot`, `gpt-6.1-sol@copilot` | GitHub Copilot, using your `gh` login: Claude models natively, GPT/Grok models translated to `/responses` |
34
+
35
+ Claude Code only accepts one `ANTHROPIC_BASE_URL` per session, so the switch has to happen behind that URL.
36
+ backend-switchboard is that URL. It reads the model name, picks a backend, swaps in that backend's credentials, and
37
+ streams the response back. Standard library only, Python 3.8+. No other services to run.
38
+
39
+ > Not affiliated with Anthropic. The Anthropic route forwards your Claude Code login (OAuth or API key) unchanged and
40
+ > never reads, stores or logs it. Check your plan's terms before sending subscription traffic through a local proxy.
41
+
42
+ ```
43
+ Claude Code --> 127.0.0.1:8790 (backend-switchboard) --+--> api.anthropic.com (no tag)
44
+ /model name[@tag] +--> argo-shim (@argo)
45
+ +--> MAG (@mag)
46
+ +--> ALCF Minerva (@alcf)
47
+ +--> AskSage (@asksage)
48
+ +--> GitHub Copilot (@copilot)
49
+ ```
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install backend-switchboard
55
+ # or without installing:
56
+ uvx backend-switchboard run
57
+ ```
58
+
59
+ ## Use
60
+
61
+ ```bash
62
+ backend-switchboard run # starts the router if needed, then launches claude
63
+ backend-switchboard run -c # anything after "run" is passed to claude
64
+ ```
65
+
66
+ Then inside Claude Code: `/model beam@alcf`, `/model claude-sonnet-5-5@argo`, `/model claude-sonnet-5-5` (back to your
67
+ subscription). You can switch mid-conversation; the history is carried across backends.
68
+
69
+ Other commands: `init` (create the key and settings), `ensure` (start the router in the background), `stop`, `serve`
70
+ (foreground). State lives in `~/.backend-switchboard/` (override with `BACKEND_SWITCHBOARD_HOME`).
71
+
72
+ If you launch Claude Code with a separate config directory, e.g. a personal subscription, export `CLAUDE_CONFIG_DIR`
73
+ first; `run` leaves it alone.
74
+
75
+ ## Backends
76
+
77
+ **Anthropic (no tag).** Nothing to configure. Whatever login your Claude Code already has is used.
78
+
79
+ **Argo (`@argo`).** Run [argo-shim](https://github.com/n-getty/argo-shim) as usual. The router re-reads its token and
80
+ local URL from `~/.claude/settings.json` on every request (change the path with `argo_settings`). If Argo answers an
81
+ exhausted quota with HTTP 200 and an "ACCESS REVOKED" notice, the router turns that into a 429 so it is not shown as a
82
+ model answer.
83
+
84
+ **MAG (`@mag`).** Export `AMSC_TOKEN` in the environment the router starts from. Set `mag_url` in `config.json` to
85
+ point at another Bearer-auth gateway. MAG's own provider suffixes stay in the name: `claude-opus-5-5@google@mag`.
86
+
87
+ **ALCF (`@alcf`).** Install the ALCF token tools and log in once, in your own terminal:
88
+
89
+ ```bash
90
+ uv tool install alcf-ai alcf-tokens
91
+ alcf-tokens login inference
92
+ ```
93
+
94
+ The router runs `alcf-tokens get-token inference` (cached for two minutes), or uses `ALCF_INFERENCE_TOKEN` if you set it.
95
+ ALCF's Minerva endpoint accepts the Anthropic Messages API directly with a Bearer token, so requests are forwarded as they
96
+ are, including streaming, tool calls and the models' thinking blocks. Aliases `beam` and `muse` map to `beam-1.0-bf16`
97
+ and `muse-spark-bf16`; any other name is passed through. Add or change aliases under `aliases.alcf` in `config.json`.
98
+
99
+ **AskSage (`@asksage`).** Setup:
100
+
101
+ 1. Get an AskSage API key from your AskSage account settings (for ANL: https://api.asksage.anl.gov) and export it in the
102
+ shell that starts the router, e.g. in `~/.bashrc`: `export ASKSAGE_API_KEY=...`. The router reads it per request and
103
+ never logs it. It reads only the environment, so restart the router after changing it: `backend-switchboard stop`,
104
+ then launch again.
105
+ 2. If your AskSage host uses a private CA, save its certificate and point at it in `~/.backend-switchboard/config.json`:
106
+ `{"asksage_cert": "~/path/to/asksage.pem"}`. `asksage_url` defaults to `https://api.asksage.anl.gov/server`; change it
107
+ for another AskSage server.
108
+ 3. Use AskSage's own model ids: `/model google-claude-opus-5@asksage`, `/model gpt-4.1@asksage`. To list the ids your
109
+ account can use, POST to `<asksage_url>/get-models` with an `x-access-tokens: $ASKSAGE_API_KEY` header.
110
+
111
+ How it works: the router translates Anthropic requests to AskSage's `/query` itself, with no asksage-proxy or llm-rosetta.
112
+ The history goes over as `me`/`gpt` turns and `tools` as a JSON list. AskSage has no field for tool results, so earlier
113
+ tool calls and their results are written into the history as prose, and answers arrive whole, so streaming is emulated.
114
+ Claude models on AskSage handle the full tool loop (Read, ToolSearch, Bash); weaker ones (`gpt-4.1`) often stall on
115
+ deferred tools. Token counts are estimates, and some AskSage models return `model_error` while their upstream is down.
116
+
117
+ **GitHub Copilot (`@copilot`).** Log in once with `gh auth login` (the account needs a Copilot seat). The router runs
118
+ `gh auth token` (cached two minutes), or uses `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN` if set, and sends
119
+ the Copilot client headers. It asks `/models` which endpoint each model supports (cached ten minutes):
120
+
121
+ - Claude models serve the Anthropic Messages API, so requests are forwarded as they are, with streaming. Claude Code's
122
+ dashed names are mapped to Copilot's dotted ones: `claude-opus-5-5@copilot` becomes `claude-opus-5.5`.
123
+ - `gpt-*`, `grok-*` and `mai-*` models only offer OpenAI's `/responses`. The router translates to it in process, including
124
+ tool calls and tool results. Answers are requested whole and streaming is emulated, and token counts for
125
+ `count_tokens` are estimates. Reasoning content is not carried across turns.
126
+ - `gemini-*` models only offer `/chat/completions` and are rejected with a 400.
127
+
128
+ To list your models, GET `https://api.githubcopilot.com/models` with `Authorization: Bearer $(gh auth token)`,
129
+ `User-Agent: GitHubCopilot/1.0.105` and `Copilot-Integration-Id: copilot-developer-cli` headers.
130
+ Add short names under `aliases.copilot` in `config.json`.
131
+
132
+ A bare `name@google` is rejected as ambiguous, because more than one backend uses that suffix. Always add a router tag.
133
+
134
+ ## Tool search
135
+
136
+ `init` sets `ENABLE_TOOL_SEARCH=true`, which defers most tool definitions and cuts the request size (about 37% fewer
137
+ tokens in one measurement with roughly 26 tools; your mix will differ). Claude Code decides this once per session, not per model, and the
138
+ Anthropic-only `tool_reference` blocks it uses are not understood by other backends. For every route except the plain
139
+ Anthropic one, the router therefore drops the `defer_loading` placeholder and rewrites each `tool_reference` into a short
140
+ text result; Claude Code already sends the loaded tool's full schema on the next request. To send every
141
+ tool definition on every request instead, set `"tool_search": "false"` in `config.json` (Claude Code also accepts
142
+ `auto:N`).
143
+
144
+ ## Configuration
145
+
146
+ Optional `~/.backend-switchboard/config.json`; every key has a default:
147
+
148
+ | key | default | meaning |
149
+ |---|---|---|
150
+ | `port` | `8790` | local listen port (loopback only) |
151
+ | `tool_search` | `true` | value written to `ENABLE_TOOL_SEARCH` in the generated Claude Code settings |
152
+ | `anthropic_url` | `https://api.anthropic.com` | no-tag route |
153
+ | `mag_url` | the AMSC MAG host | `@mag` route |
154
+ | `argo_settings` / `argo_url` | `~/.claude/settings.json` / read from it | where to find argo-shim |
155
+ | `asksage_url` / `asksage_cert` | `https://api.asksage.anl.gov/server` / none | AskSage server and optional extra CA bundle |
156
+ | `copilot_url` | `https://api.githubcopilot.com` | `@copilot` route |
157
+ | `alcf_url` | ALCF Minerva `/api` | endpoint behind `@alcf` (Anthropic Messages API) |
158
+ | `aliases` | `beam`, `muse` | short names per backend (`alcf`, `copilot`) |
159
+
160
+ ## Security
161
+
162
+ - The router listens on `127.0.0.1` only and requires a random key on every request, sent by Claude Code through
163
+ `ANTHROPIC_CUSTOM_HEADERS`. This matters on shared login nodes: without it, any other user on the machine could spend
164
+ your Argo, MAG and ALCF credentials. The key and `settings.json` are created with mode 600.
165
+ - Upstream credentials are read per request and are never logged. The log records the model name, backend, status and
166
+ duration only.
167
+
168
+ ## Status and limits
169
+
170
+ - Tested live: your Anthropic login, ALCF Beam and Muse, and a single conversation switched across backends between turns.
171
+ The Argo and MAG routes are covered by mock-upstream tests only.
172
+ - Beam handled loading a deferred tool with `ToolSearch` and then calling it reliably in every run. Muse was inconsistent: in two
173
+ runs, one succeeded and one looped on tool calls until the client timed out. All requests returned 200, so this is model
174
+ behavior, not routing.
175
+ - When a backend is down or returns a 5xx, Claude Code retries for a few minutes, so a stopped Argo tunnel looks like a
176
+ hang. Check `~/.backend-switchboard/router.log`.
177
+ - ALCF does not implement the token-count endpoint (404). Claude Code falls back and the turn completes.
178
+ - Switching between an Anthropic model and ALCF models worked in testing (history includes the ALCF thinking blocks), but not
179
+ with extended thinking enabled on the Anthropic side.
180
+ - Not covered: the interactive `/model` picker UI (typing the name works), and prompt-cache behavior across backends.
181
+
182
+ ## Tests
183
+
184
+ ```bash
185
+ python3 tests/selftest.py
186
+ ```
187
+
188
+ See [tests/README.md](tests/README.md). Releases are cut by merging to `main`; the workflow bumps the version, updates
189
+ the changelog and publishes to PyPI.
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,10 @@
1
+ backend_switchboard/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ backend_switchboard/__main__.py,sha256=SKaP_RReP-RQ3x5u2gNaZqeRMjTJtiYh2_Gfj1d1O20,53
3
+ backend_switchboard/_asksage.py,sha256=kj78cLv1yt2zp0P2lff6FeVB9h0F6LGqnaKcwAnxKpU,9926
4
+ backend_switchboard/_copilot.py,sha256=6Jj4IInRvE4x_xwc7FCVy0siZYWz6GjBUyQNP8cpxK0,10354
5
+ backend_switchboard/_router.py,sha256=N5i29QmDICqmQxODPa9xwsd_YEHMTR92pDI_At0qbA0,17522
6
+ backend_switchboard-0.1.1.dist-info/METADATA,sha256=oWL-3-NBBy-vvKQWIFoR3-pjZQlNRda5VX79lD3Z0zg,11393
7
+ backend_switchboard-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ backend_switchboard-0.1.1.dist-info/entry_points.txt,sha256=F2aZFZB9wj-odrAdN_USA3eyeyJdjBuAQtl6zMKAkBQ,73
9
+ backend_switchboard-0.1.1.dist-info/licenses/LICENSE,sha256=dyrk9yhDJCreFPXfRA4-ZaJqobDr-MfswN1gG9c8M90,1067
10
+ backend_switchboard-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ backend-switchboard = backend_switchboard._router:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neil Getty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.