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.
@@ -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,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
6
+ .pytest_cache/
7
+ uv.lock
@@ -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"
@@ -0,0 +1,3 @@
1
+ from backend_switchboard._router import main
2
+
3
+ main()