subrouter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: subrouter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Routes AI coding-agent traffic across subscription accounts and API keys.
|
|
5
|
+
Project-URL: Homepage, https://github.com/manaflow-ai/subrouter
|
|
6
|
+
Project-URL: Repository, https://github.com/manaflow-ai/subrouter
|
|
7
|
+
Project-URL: Issues, https://github.com/manaflow-ai/subrouter/issues
|
|
8
|
+
Author: Manaflow
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Go
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Software Development
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Subrouter
|
|
23
|
+
|
|
24
|
+
Subrouter is a local AI coding-agent proxy. It routes traffic across Codex accounts with sticky conversation-to-account assignment so cached context stays useful.
|
|
25
|
+
|
|
26
|
+
## Goals
|
|
27
|
+
|
|
28
|
+
- Run fast on a Mac Mini.
|
|
29
|
+
- Forward requests with normal Go reverse-proxy behavior, including headers and streaming responses.
|
|
30
|
+
- Support subscription accounts first, API keys second.
|
|
31
|
+
- Keep each conversation pinned to one account.
|
|
32
|
+
- Pick a fresh account for a new conversation based on available rate-limit headroom.
|
|
33
|
+
- Provide the Codex account manager and daemon in one Go binary.
|
|
34
|
+
|
|
35
|
+
## Current shape
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
make accounts
|
|
39
|
+
make run
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This repo sets `CGO_ENABLED=0` in `Makefile` because the local macOS Go 1.22 toolchain is currently producing cgo test binaries that fail before startup with `missing LC_UUID load command`.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
Install with npm:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install -g subrouter
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install with Python:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install subrouter
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Both packages install `subrouter`, `sr`, and `cx`. The wrappers download the matching Go release binary for macOS, Linux, Windows, FreeBSD, OpenBSD, or NetBSD on amd64, arm64, or supported 32-bit variants. Set `SUBROUTER_BIN` to use a local binary instead.
|
|
59
|
+
|
|
60
|
+
## Local macOS daemon
|
|
61
|
+
|
|
62
|
+
On macOS, install Subrouter as a localhost-only LaunchAgent:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
make build
|
|
66
|
+
./bin/subrouter install-daemon
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This installs the binary to `~/bin/subrouter`, installs `~/bin/cx` as a symlink to the same Go binary, writes `~/Library/LaunchAgents/ai.manaflow.subrouter.plist`, creates `~/.subrouter/transcripts`, starts the service, and runs:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
~/bin/subrouter serve --addr 127.0.0.1:31415 --transcripts ~/.subrouter/transcripts --cx-switch-interval 10m
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The 10 minute `cx` auto-switch interval is the default. Override it with `subrouter install-daemon --cx-switch-interval 5m`, or disable it with `--cx-switch-interval 0`. This command is macOS-specific; use a systemd unit or another supervisor on Linux.
|
|
76
|
+
|
|
77
|
+
Useful endpoints:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
GET /_subrouter/health
|
|
81
|
+
GET /_subrouter/accounts
|
|
82
|
+
GET /_subrouter/sessions
|
|
83
|
+
GET /_subrouter/dashboard
|
|
84
|
+
GET /_subrouter/transcripts
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## GCP deployment
|
|
88
|
+
|
|
89
|
+
See [deploy/gcp/README.md](deploy/gcp/README.md) for the small GCP + Tailscale Subrouter deployment flow.
|
|
90
|
+
|
|
91
|
+
To persist raw Subrouter transcripts, pass a transcript directory:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
subrouter serve --transcripts ~/.subrouter/transcripts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Transcripts are JSONL files keyed by agent type and session id under `by-agent/<agent-type>/by-session/<agent-session-id>.jsonl`. They include Subrouter metadata, redacted headers, HTTP bodies, SSE bodies, and WebSocket message payloads as base64 with byte counts and SHA-256 hashes. Each event includes `agent_type` and `agent_session_id`; Codex events also include `codex_session_id` for matching `~/.codex/sessions` JSONL files. This is intentionally storage-heavy and can contain sensitive request/response payloads. Authorization-style headers are redacted, but bodies are stored in full.
|
|
98
|
+
|
|
99
|
+
When transcript recording is enabled, `/_subrouter/dashboard` serves an internal HTML dashboard over the same Subrouter listener. It shows token usage over time, usage by user email, usage by selected account, session assignments, transcript summaries, and links to sanitized transcript event JSON under `/_subrouter/transcripts/<agent-type>/<session-id>`. Raw internal trajectory JSON with decoded body text is available under `/_subrouter/transcripts/<agent-type>/<session-id>/raw`.
|
|
100
|
+
|
|
101
|
+
To mirror transcripts to GCS without blocking proxy requests, also pass a `gs://` destination:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
subrouter serve --transcripts ~/.subrouter/transcripts --transcript-gcs-uri gs://bucket/prefix
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The daemon shells out to `gsutil -m rsync -r` on a background interval. Local transcript writes stay on the request path; GCS upload failures are logged and retried later.
|
|
108
|
+
|
|
109
|
+
For best cache behavior, clients should send a stable header per conversation:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
X-Subrouter-Session: <conversation-or-thread-id>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If that header is missing, Subrouter checks Codex headers such as `x-codex-window-id` and `x-codex-turn-state`, common session headers, query params, and small JSON bodies for `session_id`, `conversation_id`, or `thread_id`.
|
|
116
|
+
|
|
117
|
+
Subrouter scopes sticky assignments and transcript files by agent type. It infers `codex`, `claude`, or `gemini` from provider session headers, and clients can set an explicit namespace:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
X-Subrouter-Agent: codex
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For teammate-level graphs, clients can also send a self-reported user header:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
X-Subrouter-User-Email: alice@example.com
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Subrouter stores the normalized email on the session assignment, includes it in proxy logs as `user`, and exposes it in `GET /_subrouter/sessions`. This is observability metadata, not authentication. To force a selected account, send `X-Subrouter-Account-ID`; API-key labels can omit the `apikey:` prefix. Subrouter strips `X-Subrouter-Session`, `X-Subrouter-Agent`, `X-Subrouter-User-Email`, `X-Subrouter-User`, `X-User-Email`, `X-Subrouter-Account-ID`, and `X-Subrouter-Account` before forwarding upstream.
|
|
130
|
+
|
|
131
|
+
## Codex CLI
|
|
132
|
+
|
|
133
|
+
`subrouter codex` is a direct Codex wrapper. Use it anywhere you would use `codex`:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
subrouter codex
|
|
137
|
+
subrouter codex exec "your prompt"
|
|
138
|
+
subrouter codex --version
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The wrapper injects this config override into the child Codex process:
|
|
142
|
+
|
|
143
|
+
```toml
|
|
144
|
+
openai_base_url = "http://127.0.0.1:31415/v1"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It does not edit Codex config or set auth environment variables. Do not set a dummy `OPENAI_API_KEY` for normal subscription routing. Leave Codex logged in the same way it already is. If Codex is in ChatGPT auth mode, `/model` keeps the subscription model picker. Subrouter replaces outbound credentials with the selected `cx` account before forwarding.
|
|
148
|
+
|
|
149
|
+
Override the subrouter URL with `SUBROUTER_CODEX_BASE_URL` if needed. See [docs/codex.md](docs/codex.md) for details and the custom-provider fallback.
|
|
150
|
+
|
|
151
|
+
Set `SUBROUTER_CODEX_USER_EMAIL` to attribute Codex traffic to a teammate:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
SUBROUTER_CODEX_USER_EMAIL=alice@example.com subrouter codex exec "your prompt"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Force a specific Subrouter account, including an API-key account, with `SUBROUTER_CODEX_ACCOUNT_ID`:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
SUBROUTER_CODEX_ACCOUNT_ID=team-codex-1 subrouter codex exec "your prompt"
|
|
161
|
+
SUBROUTER_CODEX_ACCOUNT_ID=apikey:team-codex-1 subrouter codex exec "your prompt"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
When either variable is set, the wrapper uses a custom `subrouter` provider with WebSockets enabled so Codex can send `X-Subrouter-User-Email` and `X-Subrouter-Account-ID`. Subrouter still replaces outbound credentials before forwarding upstream. `SUBROUTER_CODEX_USER_EMAIL` is only teammate observability metadata; account selection belongs in `SUBROUTER_CODEX_ACCOUNT_ID`.
|
|
165
|
+
|
|
166
|
+
## Codex accounts
|
|
167
|
+
|
|
168
|
+
Subrouter has a native Go implementation of the Codex account manager. It reads and writes the existing `cx` store format:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
~/.codex-accounts/accounts/*.json
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Account-management commands are built into the `subrouter` binary:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
go run ./cmd/subrouter cx add
|
|
178
|
+
go run ./cmd/subrouter cx import
|
|
179
|
+
go run ./cmd/subrouter cx list
|
|
180
|
+
go run ./cmd/subrouter cx status
|
|
181
|
+
|
|
182
|
+
# direct aliases also work
|
|
183
|
+
go run ./cmd/subrouter status
|
|
184
|
+
sr status
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The supported Codex commands include `add`, `add-key`, `import`, `list`, `switch`, `gui-switch`, `remove`, `status`, `usage`, `add-admin-key`, `admin-keys`, `remove-admin-key`, and `attach-project`.
|
|
188
|
+
|
|
189
|
+
`cx switch` also syncs compatible ChatGPT Codex credentials into:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
~/.codex/auth.json
|
|
193
|
+
~/.local/share/opencode/auth.json # provider key: openai
|
|
194
|
+
~/.pi/agent/auth.json # provider key: openai-codex
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
OpenCode uses XDG data home, so `XDG_DATA_HOME` changes its auth path. pi uses `PI_CODING_AGENT_DIR` when set. Existing unrelated provider credentials in those files are preserved.
|
|
198
|
+
|
|
199
|
+
Claude profiles are also native Go and use the existing `~/.codex-accounts/claude.json` format:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
cx claude list
|
|
203
|
+
cx claude switch <profile>
|
|
204
|
+
cx claude env
|
|
205
|
+
cx claude run <profile>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Gemini has its own `cx gemini` namespace and store scaffold so future routing cannot collide with Codex or Claude state.
|
|
209
|
+
|
|
210
|
+
## Selection policy
|
|
211
|
+
|
|
212
|
+
On startup, Subrouter fetches current Codex usage for OAuth accounts and scores each account by its most constrained usage window. The scheduler keeps existing sessions sticky. For a new session it protects low-headroom accounts, spends healthy quota that resets soonest, then breaks ties by live assigned-session counts.
|
|
213
|
+
If all else ties, subscription OAuth accounts are preferred before API-key accounts.
|
|
214
|
+
|
|
215
|
+
The daemon also refreshes usage and updates Codex, OpenCode, and pi auth every 10 minutes by default so local agents follow the same OAuth-only policy. Configure it with `subrouter serve --cx-switch-interval 5m`, or disable it with `--cx-switch-interval 0`. If `--fetch-usage=false`, auto-switch is disabled because fresh usage is required.
|
|
216
|
+
|
|
217
|
+
By default, OAuth accounts are forwarded to `https://chatgpt.com/backend-api/codex` and API-key accounts are forwarded to `https://api.openai.com`. Subrouter accepts either `/v1/responses` or `/responses` from clients and normalizes the path for the selected account type.
|
|
218
|
+
|
|
219
|
+
Live headroom comes from Codex subscription usage. API-key spend comes from the OpenAI organization usage endpoints through stored `sk-admin-*` keys. Claude profile usage comes from the Anthropic OAuth usage endpoint when profile credentials are readable.
|
|
220
|
+
|
|
221
|
+
See [docs/saturation.md](docs/saturation.md) for the 5h/7d placement strategy and simulation tests.
|
|
222
|
+
|
|
223
|
+
## Security defaults
|
|
224
|
+
|
|
225
|
+
- Bind to `127.0.0.1` unless explicitly exposed.
|
|
226
|
+
- Do not log tokens, refresh tokens, API keys, request bodies, or full Authorization headers.
|
|
227
|
+
- Keep `~/.codex-accounts` credentials as the canonical local store.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
subrouter_cli/__init__.py,sha256=YYSKVwDVttepU9n_I7PYfeVi-Fgmu9SeHnme3zHZUP0,2963
|
|
2
|
+
subrouter_cli/__main__.py,sha256=XT16__oFTTaLu_dAxDoytthcfBJ6c3B2SfJf034qgvU,37
|
|
3
|
+
subrouter-0.1.0.dist-info/METADATA,sha256=xK4t_xpnIIAtEGJTaJfhTpR53PwipRi4949810s9CN0,10543
|
|
4
|
+
subrouter-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
subrouter-0.1.0.dist-info/entry_points.txt,sha256=MKOCvTZ6QB3fLx3jhUK4M0Os2zYWlFYYG1ED1pnDdo8,98
|
|
6
|
+
subrouter-0.1.0.dist-info/licenses/LICENSE,sha256=8A95MtIz2_D6SxEzEEoFdoXDYZhpLGxpCO-n3UNGvwM,1065
|
|
7
|
+
subrouter-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manaflow
|
|
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,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
VERSION = "0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _go_platform() -> str:
|
|
16
|
+
system = platform.system().lower()
|
|
17
|
+
mapping = {
|
|
18
|
+
"darwin": "darwin",
|
|
19
|
+
"linux": "linux",
|
|
20
|
+
"windows": "windows",
|
|
21
|
+
"freebsd": "freebsd",
|
|
22
|
+
"openbsd": "openbsd",
|
|
23
|
+
"netbsd": "netbsd",
|
|
24
|
+
}
|
|
25
|
+
if system not in mapping:
|
|
26
|
+
raise SystemExit(f"Unsupported platform: {system}")
|
|
27
|
+
return mapping[system]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _go_arch() -> str:
|
|
31
|
+
machine = platform.machine().lower()
|
|
32
|
+
mapping = {
|
|
33
|
+
"x86_64": "amd64",
|
|
34
|
+
"amd64": "amd64",
|
|
35
|
+
"aarch64": "arm64",
|
|
36
|
+
"arm64": "arm64",
|
|
37
|
+
"i386": "386",
|
|
38
|
+
"i686": "386",
|
|
39
|
+
"armv7l": "armv7",
|
|
40
|
+
"armv6l": "armv6",
|
|
41
|
+
}
|
|
42
|
+
if machine not in mapping:
|
|
43
|
+
raise SystemExit(f"Unsupported architecture: {machine}")
|
|
44
|
+
return mapping[machine]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _download(url: str) -> bytes:
|
|
48
|
+
with urllib.request.urlopen(url, timeout=60) as response:
|
|
49
|
+
return response.read()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _verify_checksum(binary: Path, asset_name: str, checksum_body: bytes) -> None:
|
|
53
|
+
expected = None
|
|
54
|
+
for line in checksum_body.decode("utf-8").splitlines():
|
|
55
|
+
parts = line.strip().split()
|
|
56
|
+
if len(parts) >= 2 and parts[1] == asset_name:
|
|
57
|
+
expected = parts[0]
|
|
58
|
+
break
|
|
59
|
+
if expected is None:
|
|
60
|
+
raise SystemExit(f"Missing checksum for {asset_name}")
|
|
61
|
+
actual = hashlib.sha256(binary.read_bytes()).hexdigest()
|
|
62
|
+
if actual != expected:
|
|
63
|
+
raise SystemExit(f"Checksum mismatch for {asset_name}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _binary() -> Path:
|
|
67
|
+
override = os.environ.get("SUBROUTER_BIN")
|
|
68
|
+
if override:
|
|
69
|
+
return Path(override)
|
|
70
|
+
|
|
71
|
+
goos = _go_platform()
|
|
72
|
+
goarch = _go_arch()
|
|
73
|
+
suffix = ".exe" if goos == "windows" else ""
|
|
74
|
+
asset_name = f"subrouter_{VERSION}_{goos}_{goarch}{suffix}"
|
|
75
|
+
cache_root = Path(os.environ.get("SUBROUTER_INSTALL_DIR", Path.home() / ".cache" / "subrouter"))
|
|
76
|
+
binary = cache_root / VERSION / asset_name
|
|
77
|
+
if binary.exists():
|
|
78
|
+
return binary
|
|
79
|
+
|
|
80
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
base_url = os.environ.get(
|
|
82
|
+
"SUBROUTER_DOWNLOAD_BASE",
|
|
83
|
+
f"https://github.com/manaflow-ai/subrouter/releases/download/v{VERSION}",
|
|
84
|
+
)
|
|
85
|
+
tmp = binary.with_name(f"{binary.name}.{os.getpid()}.tmp")
|
|
86
|
+
tmp.write_bytes(_download(f"{base_url}/{asset_name}"))
|
|
87
|
+
try:
|
|
88
|
+
_verify_checksum(tmp, asset_name, _download(f"{base_url}/SHA256SUMS"))
|
|
89
|
+
except BaseException:
|
|
90
|
+
tmp.unlink(missing_ok=True)
|
|
91
|
+
raise
|
|
92
|
+
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
93
|
+
tmp.replace(binary)
|
|
94
|
+
return binary
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run(command_name: str) -> None:
|
|
98
|
+
os.execv(_binary(), [command_name, *sys.argv[1:]])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def subrouter() -> None:
|
|
102
|
+
_run("subrouter")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sr() -> None:
|
|
106
|
+
_run("sr")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cx() -> None:
|
|
110
|
+
_run("cx")
|