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.
- backend_switchboard/__init__.py +1 -0
- backend_switchboard/__main__.py +3 -0
- backend_switchboard/_asksage.py +216 -0
- backend_switchboard/_copilot.py +230 -0
- backend_switchboard/_router.py +450 -0
- backend_switchboard-0.1.1.dist-info/METADATA +193 -0
- backend_switchboard-0.1.1.dist-info/RECORD +10 -0
- backend_switchboard-0.1.1.dist-info/WHEEL +4 -0
- backend_switchboard-0.1.1.dist-info/entry_points.txt +2 -0
- backend_switchboard-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -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,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.
|