backend-switchboard 0.1.1__tar.gz
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-0.1.1/.github/workflows/ci.yml +20 -0
- backend_switchboard-0.1.1/.github/workflows/publish.yml +216 -0
- backend_switchboard-0.1.1/.gitignore +7 -0
- backend_switchboard-0.1.1/CHANGELOG.md +18 -0
- backend_switchboard-0.1.1/LICENSE +21 -0
- backend_switchboard-0.1.1/PKG-INFO +193 -0
- backend_switchboard-0.1.1/README.md +172 -0
- backend_switchboard-0.1.1/backend_switchboard/__init__.py +1 -0
- backend_switchboard-0.1.1/backend_switchboard/__main__.py +3 -0
- backend_switchboard-0.1.1/backend_switchboard/_asksage.py +216 -0
- backend_switchboard-0.1.1/backend_switchboard/_copilot.py +230 -0
- backend_switchboard-0.1.1/backend_switchboard/_router.py +450 -0
- backend_switchboard-0.1.1/pyproject.toml +34 -0
- backend_switchboard-0.1.1/tests/README.md +11 -0
- backend_switchboard-0.1.1/tests/selftest.py +225 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
selftest:
|
|
10
|
+
runs-on: ubuntu-22.04 # ubuntu-latest no longer ships Python 3.8
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.8", "3.11"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: python tests/selftest.py
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
name: Release & Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
pull_request:
|
|
8
|
+
types: [closed]
|
|
9
|
+
branches: [main]
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write
|
|
13
|
+
pull-requests: read
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: release
|
|
18
|
+
cancel-in-progress: false
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
release-and-publish:
|
|
22
|
+
if: >-
|
|
23
|
+
(github.event_name == 'push') ||
|
|
24
|
+
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
env:
|
|
27
|
+
VERSION_BUMP: patch
|
|
28
|
+
IS_RELEASE: ${{ github.event_name == 'pull_request' }}
|
|
29
|
+
steps:
|
|
30
|
+
- name: Checkout
|
|
31
|
+
uses: actions/checkout@v4
|
|
32
|
+
with:
|
|
33
|
+
fetch-depth: 0
|
|
34
|
+
|
|
35
|
+
- name: Install uv
|
|
36
|
+
uses: astral-sh/setup-uv@v6
|
|
37
|
+
|
|
38
|
+
- name: Set up Python
|
|
39
|
+
run: uv python install
|
|
40
|
+
|
|
41
|
+
# --- Release steps (PR merge only) ---
|
|
42
|
+
|
|
43
|
+
- name: Determine bump level
|
|
44
|
+
id: bump_level
|
|
45
|
+
if: env.IS_RELEASE == 'true'
|
|
46
|
+
shell: bash
|
|
47
|
+
run: |
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
|
|
50
|
+
bump="${VERSION_BUMP:-patch}"
|
|
51
|
+
|
|
52
|
+
labels="$(python - <<'PY'
|
|
53
|
+
import json, os
|
|
54
|
+
|
|
55
|
+
path = os.environ.get("GITHUB_EVENT_PATH", "")
|
|
56
|
+
event = {}
|
|
57
|
+
if path:
|
|
58
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
59
|
+
event = json.load(fh)
|
|
60
|
+
|
|
61
|
+
pr = event.get("pull_request", {}) or {}
|
|
62
|
+
labels = pr.get("labels", []) or []
|
|
63
|
+
names = [ (l.get("name","") or "").strip().lower() for l in labels ]
|
|
64
|
+
print(",".join([n for n in names if n]))
|
|
65
|
+
PY
|
|
66
|
+
)"
|
|
67
|
+
|
|
68
|
+
echo "PR labels: ${labels:-<none>}"
|
|
69
|
+
|
|
70
|
+
# Skip labels
|
|
71
|
+
if [[ ",${labels}," == *",release:skip,"* ]] || \
|
|
72
|
+
[[ ",${labels}," == *",skip-release,"* ]] || \
|
|
73
|
+
[[ ",${labels}," == *",no-release,"* ]] || \
|
|
74
|
+
[[ ",${labels}," == *",skip-version-bump,"* ]]; then
|
|
75
|
+
echo "Release/version bump skipped due to label."
|
|
76
|
+
echo "skip_release=true" >> "$GITHUB_OUTPUT"
|
|
77
|
+
echo "bump=none" >> "$GITHUB_OUTPUT"
|
|
78
|
+
exit 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Bump labels (last one wins if multiple)
|
|
82
|
+
if [[ ",${labels}," == *",release:major,"* ]] || [[ ",${labels}," == *",semver:major,"* ]]; then
|
|
83
|
+
bump="major"
|
|
84
|
+
fi
|
|
85
|
+
if [[ ",${labels}," == *",release:minor,"* ]] || [[ ",${labels}," == *",semver:minor,"* ]]; then
|
|
86
|
+
bump="minor"
|
|
87
|
+
fi
|
|
88
|
+
if [[ ",${labels}," == *",release:patch,"* ]] || [[ ",${labels}," == *",semver:patch,"* ]]; then
|
|
89
|
+
bump="patch"
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
echo "Using bump level: ${bump}"
|
|
93
|
+
echo "skip_release=false" >> "$GITHUB_OUTPUT"
|
|
94
|
+
echo "bump=${bump}" >> "$GITHUB_OUTPUT"
|
|
95
|
+
|
|
96
|
+
- name: Install hatch
|
|
97
|
+
if: env.IS_RELEASE == 'true' && steps.bump_level.outputs.skip_release != 'true'
|
|
98
|
+
run: uv tool install --upgrade hatch
|
|
99
|
+
|
|
100
|
+
- name: Bump version
|
|
101
|
+
id: bump
|
|
102
|
+
if: env.IS_RELEASE == 'true' && steps.bump_level.outputs.skip_release != 'true'
|
|
103
|
+
run: |
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
old_version="$(hatch version)"
|
|
106
|
+
bump="${{ steps.bump_level.outputs.bump }}"
|
|
107
|
+
echo "Bumping version (${bump}) from ${old_version}"
|
|
108
|
+
hatch version "${bump}"
|
|
109
|
+
new_version="$(hatch version)"
|
|
110
|
+
echo "NEW_VERSION=${new_version}" >> "$GITHUB_ENV"
|
|
111
|
+
echo "new_version=${new_version}" >> "$GITHUB_OUTPUT"
|
|
112
|
+
|
|
113
|
+
- name: Generate changelog entry
|
|
114
|
+
id: changelog
|
|
115
|
+
if: env.IS_RELEASE == 'true' && steps.bump_level.outputs.skip_release != 'true'
|
|
116
|
+
env:
|
|
117
|
+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
|
|
118
|
+
REPO: ${{ github.repository }}
|
|
119
|
+
run: |
|
|
120
|
+
set -euo pipefail
|
|
121
|
+
PREV_TAG=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null || true)
|
|
122
|
+
export PREV_TAG
|
|
123
|
+
python - <<'PY'
|
|
124
|
+
import datetime
|
|
125
|
+
import os
|
|
126
|
+
import subprocess
|
|
127
|
+
from pathlib import Path
|
|
128
|
+
|
|
129
|
+
new_version = os.environ["NEW_VERSION"]
|
|
130
|
+
repo = os.environ["REPO"]
|
|
131
|
+
prev_tag = os.environ.get("PREV_TAG") or ""
|
|
132
|
+
|
|
133
|
+
log_range = "HEAD" if not prev_tag else f"{prev_tag}..HEAD"
|
|
134
|
+
SEP = "\x1f" # unit separator — safe delimiter (cannot appear in subjects)
|
|
135
|
+
raw_commits = subprocess.run(
|
|
136
|
+
["git", "log", f"--pretty=format:%s{SEP}%H", log_range],
|
|
137
|
+
check=True,
|
|
138
|
+
text=True,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
).stdout.strip().splitlines()
|
|
141
|
+
|
|
142
|
+
commits = []
|
|
143
|
+
for line in raw_commits:
|
|
144
|
+
if SEP not in line:
|
|
145
|
+
continue
|
|
146
|
+
subject, commit = line.split(SEP, 1)
|
|
147
|
+
commits.append(f"- {subject} [`{commit[:7]}`](https://github.com/{repo}/commit/{commit})")
|
|
148
|
+
|
|
149
|
+
if not commits:
|
|
150
|
+
commits.append("- No notable changes recorded.")
|
|
151
|
+
|
|
152
|
+
released_on = datetime.datetime.utcnow().strftime("%-d %B %Y")
|
|
153
|
+
|
|
154
|
+
tag = f"v{new_version}"
|
|
155
|
+
entry = (
|
|
156
|
+
f"#### [{tag}](https://github.com/{repo}/releases/tag/{tag})\n\n"
|
|
157
|
+
f"> {released_on}\n\n"
|
|
158
|
+
+ "\n".join(commits)
|
|
159
|
+
+ "\n\n"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
changelog = Path("CHANGELOG.md")
|
|
163
|
+
content = changelog.read_text().splitlines()
|
|
164
|
+
insert_at = len(content)
|
|
165
|
+
for idx, line in enumerate(content):
|
|
166
|
+
if line.startswith("Generated by"):
|
|
167
|
+
insert_at = idx + 1
|
|
168
|
+
while insert_at < len(content) and content[insert_at].strip() == "":
|
|
169
|
+
insert_at += 1
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
new_content = content[:insert_at] + ["", entry.rstrip(), ""] + content[insert_at:]
|
|
173
|
+
changelog.write_text("\n".join(new_content).rstrip() + "\n")
|
|
174
|
+
|
|
175
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
|
|
176
|
+
fh.write("body<<EOF\n")
|
|
177
|
+
fh.write(entry)
|
|
178
|
+
fh.write("\nEOF\n")
|
|
179
|
+
PY
|
|
180
|
+
|
|
181
|
+
- name: Commit version and changelog
|
|
182
|
+
if: env.IS_RELEASE == 'true' && steps.bump_level.outputs.skip_release != 'true'
|
|
183
|
+
env:
|
|
184
|
+
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
|
|
185
|
+
run: |
|
|
186
|
+
set -euo pipefail
|
|
187
|
+
git config user.name "github-actions[bot]"
|
|
188
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
189
|
+
git add backend_switchboard/__init__.py CHANGELOG.md
|
|
190
|
+
git commit -m "chore: release v${NEW_VERSION}"
|
|
191
|
+
git tag "v${NEW_VERSION}"
|
|
192
|
+
git fetch origin main
|
|
193
|
+
git rebase origin/main
|
|
194
|
+
git push origin HEAD:main
|
|
195
|
+
git push origin --tags
|
|
196
|
+
|
|
197
|
+
- name: Publish GitHub release
|
|
198
|
+
if: env.IS_RELEASE == 'true' && steps.bump_level.outputs.skip_release != 'true'
|
|
199
|
+
uses: softprops/action-gh-release@v1
|
|
200
|
+
with:
|
|
201
|
+
tag_name: v${{ steps.bump.outputs.new_version }}
|
|
202
|
+
name: v${{ steps.bump.outputs.new_version }}
|
|
203
|
+
body: ${{ steps.changelog.outputs.body }}
|
|
204
|
+
env:
|
|
205
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
206
|
+
|
|
207
|
+
# --- Publish steps ---
|
|
208
|
+
# On tag push: always publish. On PR merge: skip if release:skip label.
|
|
209
|
+
|
|
210
|
+
- name: Build package
|
|
211
|
+
if: github.event_name == 'push' || steps.bump_level.outputs.skip_release != 'true'
|
|
212
|
+
run: uv build
|
|
213
|
+
|
|
214
|
+
- name: Publish to PyPI
|
|
215
|
+
if: github.event_name == 'push' || steps.bump_level.outputs.skip_release != 'true'
|
|
216
|
+
run: uv publish
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Generated by [Release & Publish](https://github.com/n-getty/backend-switchboard/actions/workflows/publish.yml).
|
|
4
|
+
|
|
5
|
+
#### [v0.1.1](https://github.com/n-getty/backend-switchboard/releases/tag/v0.1.1)
|
|
6
|
+
|
|
7
|
+
> 12 November 2026
|
|
8
|
+
|
|
9
|
+
- Merge pull request #2 from n-getty/feat/copilot-backend [`99f877c`](https://github.com/n-getty/backend-switchboard/commit/99f877c5d4fdcf7a47572bbea898b59baee38a62)
|
|
10
|
+
- fix: harden @copilot model lookup, name mapping and error handling [`0a8b936`](https://github.com/n-getty/backend-switchboard/commit/0a8b93628cff57f6406a6f93408e29ac047f6149)
|
|
11
|
+
- feat: route @copilot to GitHub Copilot (Claude native, gpt/grok via /responses, stdlib only) [`9625624`](https://github.com/n-getty/backend-switchboard/commit/96256242cd921b35b6be15fb15dc5cab23fd9e7f)
|
|
12
|
+
- docs: add AskSage setup steps [`9d3b0e0`](https://github.com/n-getty/backend-switchboard/commit/9d3b0e0f332c054526e2bbf20b12f6f3cb22b172)
|
|
13
|
+
- feat: route @asksage to AskSage's /query API in process (stdlib, no proxy or gateway) [`23046f4`](https://github.com/n-getty/backend-switchboard/commit/23046f42daa0104690171ddac332a42b09d65ba4)
|
|
14
|
+
- fix: strip Claude Code's Anthropic-only message extras on non-subscription routes [`d144554`](https://github.com/n-getty/backend-switchboard/commit/d1445546a99c25a2a5326f631d146694b868799d)
|
|
15
|
+
- feat!: route @alcf natively and drop the llm-rosetta dependency [`ec5a6a8`](https://github.com/n-getty/backend-switchboard/commit/ec5a6a86ad3d7a8ee8a9f7b9a2bc64bd2b25c958)
|
|
16
|
+
- test: exit the self-test explicitly to avoid a Python 3.8 shutdown abort [`38a3de3`](https://github.com/n-getty/backend-switchboard/commit/38a3de30b4f3d9cbfb49f0d942321c8a3a633bf7)
|
|
17
|
+
- ci: run the self-test on ubuntu-22.04 so Python 3.8 is available [`e01e555`](https://github.com/n-getty/backend-switchboard/commit/e01e555872c0ce869f936ff4c307a4f4c205e22b)
|
|
18
|
+
- feat: initial release of backend-switchboard [`02a0fcf`](https://github.com/n-getty/backend-switchboard/commit/02a0fcf8844f37b30b836dde6fa2cb318bae1089)
|
|
@@ -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.
|
|
@@ -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,172 @@
|
|
|
1
|
+
# backend-switchboard
|
|
2
|
+
|
|
3
|
+
One local endpoint that lets Claude Code switch between several model backends with `/model`, in a single session:
|
|
4
|
+
|
|
5
|
+
| `/model` value | Goes to |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `claude-sonnet-5-5` (no tag) | Anthropic, using your own Claude Code login. Requests are forwarded untouched. |
|
|
8
|
+
| `claude-sonnet-5-5@argo` | [Argo](https://github.com/n-getty/argo-shim) through a running `argo-shim` |
|
|
9
|
+
| `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 |
|
|
10
|
+
| `beam@alcf`, `muse@alcf` | ALCF inference endpoints, which serve the Anthropic Messages API natively |
|
|
11
|
+
| `google-claude-opus-5@asksage` | AskSage models, translated in-process to AskSage's `/query` API |
|
|
12
|
+
| `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` |
|
|
13
|
+
|
|
14
|
+
Claude Code only accepts one `ANTHROPIC_BASE_URL` per session, so the switch has to happen behind that URL.
|
|
15
|
+
backend-switchboard is that URL. It reads the model name, picks a backend, swaps in that backend's credentials, and
|
|
16
|
+
streams the response back. Standard library only, Python 3.8+. No other services to run.
|
|
17
|
+
|
|
18
|
+
> Not affiliated with Anthropic. The Anthropic route forwards your Claude Code login (OAuth or API key) unchanged and
|
|
19
|
+
> never reads, stores or logs it. Check your plan's terms before sending subscription traffic through a local proxy.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Claude Code --> 127.0.0.1:8790 (backend-switchboard) --+--> api.anthropic.com (no tag)
|
|
23
|
+
/model name[@tag] +--> argo-shim (@argo)
|
|
24
|
+
+--> MAG (@mag)
|
|
25
|
+
+--> ALCF Minerva (@alcf)
|
|
26
|
+
+--> AskSage (@asksage)
|
|
27
|
+
+--> GitHub Copilot (@copilot)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install backend-switchboard
|
|
34
|
+
# or without installing:
|
|
35
|
+
uvx backend-switchboard run
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
backend-switchboard run # starts the router if needed, then launches claude
|
|
42
|
+
backend-switchboard run -c # anything after "run" is passed to claude
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then inside Claude Code: `/model beam@alcf`, `/model claude-sonnet-5-5@argo`, `/model claude-sonnet-5-5` (back to your
|
|
46
|
+
subscription). You can switch mid-conversation; the history is carried across backends.
|
|
47
|
+
|
|
48
|
+
Other commands: `init` (create the key and settings), `ensure` (start the router in the background), `stop`, `serve`
|
|
49
|
+
(foreground). State lives in `~/.backend-switchboard/` (override with `BACKEND_SWITCHBOARD_HOME`).
|
|
50
|
+
|
|
51
|
+
If you launch Claude Code with a separate config directory, e.g. a personal subscription, export `CLAUDE_CONFIG_DIR`
|
|
52
|
+
first; `run` leaves it alone.
|
|
53
|
+
|
|
54
|
+
## Backends
|
|
55
|
+
|
|
56
|
+
**Anthropic (no tag).** Nothing to configure. Whatever login your Claude Code already has is used.
|
|
57
|
+
|
|
58
|
+
**Argo (`@argo`).** Run [argo-shim](https://github.com/n-getty/argo-shim) as usual. The router re-reads its token and
|
|
59
|
+
local URL from `~/.claude/settings.json` on every request (change the path with `argo_settings`). If Argo answers an
|
|
60
|
+
exhausted quota with HTTP 200 and an "ACCESS REVOKED" notice, the router turns that into a 429 so it is not shown as a
|
|
61
|
+
model answer.
|
|
62
|
+
|
|
63
|
+
**MAG (`@mag`).** Export `AMSC_TOKEN` in the environment the router starts from. Set `mag_url` in `config.json` to
|
|
64
|
+
point at another Bearer-auth gateway. MAG's own provider suffixes stay in the name: `claude-opus-5-5@google@mag`.
|
|
65
|
+
|
|
66
|
+
**ALCF (`@alcf`).** Install the ALCF token tools and log in once, in your own terminal:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv tool install alcf-ai alcf-tokens
|
|
70
|
+
alcf-tokens login inference
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The router runs `alcf-tokens get-token inference` (cached for two minutes), or uses `ALCF_INFERENCE_TOKEN` if you set it.
|
|
74
|
+
ALCF's Minerva endpoint accepts the Anthropic Messages API directly with a Bearer token, so requests are forwarded as they
|
|
75
|
+
are, including streaming, tool calls and the models' thinking blocks. Aliases `beam` and `muse` map to `beam-1.0-bf16`
|
|
76
|
+
and `muse-spark-bf16`; any other name is passed through. Add or change aliases under `aliases.alcf` in `config.json`.
|
|
77
|
+
|
|
78
|
+
**AskSage (`@asksage`).** Setup:
|
|
79
|
+
|
|
80
|
+
1. Get an AskSage API key from your AskSage account settings (for ANL: https://api.asksage.anl.gov) and export it in the
|
|
81
|
+
shell that starts the router, e.g. in `~/.bashrc`: `export ASKSAGE_API_KEY=...`. The router reads it per request and
|
|
82
|
+
never logs it. It reads only the environment, so restart the router after changing it: `backend-switchboard stop`,
|
|
83
|
+
then launch again.
|
|
84
|
+
2. If your AskSage host uses a private CA, save its certificate and point at it in `~/.backend-switchboard/config.json`:
|
|
85
|
+
`{"asksage_cert": "~/path/to/asksage.pem"}`. `asksage_url` defaults to `https://api.asksage.anl.gov/server`; change it
|
|
86
|
+
for another AskSage server.
|
|
87
|
+
3. Use AskSage's own model ids: `/model google-claude-opus-5@asksage`, `/model gpt-4.1@asksage`. To list the ids your
|
|
88
|
+
account can use, POST to `<asksage_url>/get-models` with an `x-access-tokens: $ASKSAGE_API_KEY` header.
|
|
89
|
+
|
|
90
|
+
How it works: the router translates Anthropic requests to AskSage's `/query` itself, with no asksage-proxy or llm-rosetta.
|
|
91
|
+
The history goes over as `me`/`gpt` turns and `tools` as a JSON list. AskSage has no field for tool results, so earlier
|
|
92
|
+
tool calls and their results are written into the history as prose, and answers arrive whole, so streaming is emulated.
|
|
93
|
+
Claude models on AskSage handle the full tool loop (Read, ToolSearch, Bash); weaker ones (`gpt-4.1`) often stall on
|
|
94
|
+
deferred tools. Token counts are estimates, and some AskSage models return `model_error` while their upstream is down.
|
|
95
|
+
|
|
96
|
+
**GitHub Copilot (`@copilot`).** Log in once with `gh auth login` (the account needs a Copilot seat). The router runs
|
|
97
|
+
`gh auth token` (cached two minutes), or uses `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN` if set, and sends
|
|
98
|
+
the Copilot client headers. It asks `/models` which endpoint each model supports (cached ten minutes):
|
|
99
|
+
|
|
100
|
+
- Claude models serve the Anthropic Messages API, so requests are forwarded as they are, with streaming. Claude Code's
|
|
101
|
+
dashed names are mapped to Copilot's dotted ones: `claude-opus-5-5@copilot` becomes `claude-opus-5.5`.
|
|
102
|
+
- `gpt-*`, `grok-*` and `mai-*` models only offer OpenAI's `/responses`. The router translates to it in process, including
|
|
103
|
+
tool calls and tool results. Answers are requested whole and streaming is emulated, and token counts for
|
|
104
|
+
`count_tokens` are estimates. Reasoning content is not carried across turns.
|
|
105
|
+
- `gemini-*` models only offer `/chat/completions` and are rejected with a 400.
|
|
106
|
+
|
|
107
|
+
To list your models, GET `https://api.githubcopilot.com/models` with `Authorization: Bearer $(gh auth token)`,
|
|
108
|
+
`User-Agent: GitHubCopilot/1.0.105` and `Copilot-Integration-Id: copilot-developer-cli` headers.
|
|
109
|
+
Add short names under `aliases.copilot` in `config.json`.
|
|
110
|
+
|
|
111
|
+
A bare `name@google` is rejected as ambiguous, because more than one backend uses that suffix. Always add a router tag.
|
|
112
|
+
|
|
113
|
+
## Tool search
|
|
114
|
+
|
|
115
|
+
`init` sets `ENABLE_TOOL_SEARCH=true`, which defers most tool definitions and cuts the request size (about 37% fewer
|
|
116
|
+
tokens in one measurement with roughly 26 tools; your mix will differ). Claude Code decides this once per session, not per model, and the
|
|
117
|
+
Anthropic-only `tool_reference` blocks it uses are not understood by other backends. For every route except the plain
|
|
118
|
+
Anthropic one, the router therefore drops the `defer_loading` placeholder and rewrites each `tool_reference` into a short
|
|
119
|
+
text result; Claude Code already sends the loaded tool's full schema on the next request. To send every
|
|
120
|
+
tool definition on every request instead, set `"tool_search": "false"` in `config.json` (Claude Code also accepts
|
|
121
|
+
`auto:N`).
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
Optional `~/.backend-switchboard/config.json`; every key has a default:
|
|
126
|
+
|
|
127
|
+
| key | default | meaning |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `port` | `8790` | local listen port (loopback only) |
|
|
130
|
+
| `tool_search` | `true` | value written to `ENABLE_TOOL_SEARCH` in the generated Claude Code settings |
|
|
131
|
+
| `anthropic_url` | `https://api.anthropic.com` | no-tag route |
|
|
132
|
+
| `mag_url` | the AMSC MAG host | `@mag` route |
|
|
133
|
+
| `argo_settings` / `argo_url` | `~/.claude/settings.json` / read from it | where to find argo-shim |
|
|
134
|
+
| `asksage_url` / `asksage_cert` | `https://api.asksage.anl.gov/server` / none | AskSage server and optional extra CA bundle |
|
|
135
|
+
| `copilot_url` | `https://api.githubcopilot.com` | `@copilot` route |
|
|
136
|
+
| `alcf_url` | ALCF Minerva `/api` | endpoint behind `@alcf` (Anthropic Messages API) |
|
|
137
|
+
| `aliases` | `beam`, `muse` | short names per backend (`alcf`, `copilot`) |
|
|
138
|
+
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
- The router listens on `127.0.0.1` only and requires a random key on every request, sent by Claude Code through
|
|
142
|
+
`ANTHROPIC_CUSTOM_HEADERS`. This matters on shared login nodes: without it, any other user on the machine could spend
|
|
143
|
+
your Argo, MAG and ALCF credentials. The key and `settings.json` are created with mode 600.
|
|
144
|
+
- Upstream credentials are read per request and are never logged. The log records the model name, backend, status and
|
|
145
|
+
duration only.
|
|
146
|
+
|
|
147
|
+
## Status and limits
|
|
148
|
+
|
|
149
|
+
- Tested live: your Anthropic login, ALCF Beam and Muse, and a single conversation switched across backends between turns.
|
|
150
|
+
The Argo and MAG routes are covered by mock-upstream tests only.
|
|
151
|
+
- Beam handled loading a deferred tool with `ToolSearch` and then calling it reliably in every run. Muse was inconsistent: in two
|
|
152
|
+
runs, one succeeded and one looped on tool calls until the client timed out. All requests returned 200, so this is model
|
|
153
|
+
behavior, not routing.
|
|
154
|
+
- When a backend is down or returns a 5xx, Claude Code retries for a few minutes, so a stopped Argo tunnel looks like a
|
|
155
|
+
hang. Check `~/.backend-switchboard/router.log`.
|
|
156
|
+
- ALCF does not implement the token-count endpoint (404). Claude Code falls back and the turn completes.
|
|
157
|
+
- Switching between an Anthropic model and ALCF models worked in testing (history includes the ALCF thinking blocks), but not
|
|
158
|
+
with extended thinking enabled on the Anthropic side.
|
|
159
|
+
- Not covered: the interactive `/model` picker UI (typing the name works), and prompt-cache behavior across backends.
|
|
160
|
+
|
|
161
|
+
## Tests
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python3 tests/selftest.py
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
See [tests/README.md](tests/README.md). Releases are cut by merging to `main`; the workflow bumps the version, updates
|
|
168
|
+
the changelog and publishes to PyPI.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|