Skip to content
Merged
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,14 @@ For a shared server, replace `127.0.0.1` with the server URL. Subrouter recogniz

Gemini has its own `sr gemini` namespace and store scaffold so future routing cannot collide with Codex or Claude state.

## Multi-tenant mode

One hosted Subrouter can serve many isolated users, each with their own account pool under `<state-dir>/tenants/<id>/` (same layout as the single-tenant state dir). `sr tenant create <name>` registers a tenant against a named server's admin API (or the local state dir on the server host) and prints an `srt_<32 hex>` key once; only its SHA-256 hash is stored in `tenants.json`.

Clients authenticate by base URL prefix, because agent CLIs can only override base URLs: point Codex at `https://host/t/<key>/v1` and Claude Code at `ANTHROPIC_BASE_URL=https://host/t/<key>`. The key is also accepted as a Bearer token or `x-api-key` header (Claude Code's `ANTHROPIC_AUTH_TOKEN` lands there). Account selection, sticky sessions, usage scoring, and transcripts are all scoped to the tenant's pool; an unknown or revoked key gets a 401. Requests without a tenant key keep the legacy single-tenant behavior.

`sr server add <name> --url <url> --tenant-key srt_...` stores the key on a server entry, after which `sr codex`, `sr claude push`, `sr server login/sync`, and the status commands operate on that tenant's pool automatically. Tenant CRUD lives on the admin-gated `/_subrouter/tenants` endpoints; tenant-scoped reads (`/t/<key>/_subrouter/{accounts,account-status,usage-status,sessions}`) are authorized by the tenant key itself.

## Selection policy

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.
Expand Down
14 changes: 10 additions & 4 deletions cmd/subrouter/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,17 @@ func codexBaseURLForNamedServer(store srServerStore, name string) (string, error
}

func codexBaseURLForServer(server srServerConfig) string {
baseURL := strings.TrimRight(server.URL, "/")
if strings.HasSuffix(baseURL, "/v1") {
return baseURL
return serverProxyRootURL(server) + "/v1"
}

// serverProxyRootURL is the data-plane root for a server entry: the bare URL,
// or the tenant-scoped /t/<key> root when the entry carries a tenant key.
func serverProxyRootURL(server srServerConfig) string {
root := codexProxyRootURL(server.URL)
if key := strings.TrimSpace(server.TenantKey); key != "" {
root += "/t/" + key
}
return baseURL + "/v1"
return root
}

func codexArgs(args []string, baseURL, userEmail, accountID string) []string {
Expand Down
19 changes: 17 additions & 2 deletions cmd/subrouter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/manaflow-ai/subrouter/internal/broker"
"github.com/manaflow-ai/subrouter/internal/proxy"
"github.com/manaflow-ai/subrouter/internal/storepath"
"github.com/manaflow-ai/subrouter/internal/tenant"
"github.com/manaflow-ai/subrouter/internal/transcript"
"github.com/manaflow-ai/subrouter/selectacct"
"github.com/manaflow-ai/subrouter/session"
Expand Down Expand Up @@ -168,6 +169,8 @@ var directSRCommands = map[string]struct{}{
"status": {},
"storage": {},
"switch": {},
"tenant": {},
"tenants": {},
"team": {},
"trace": {},
"usage": {},
Expand Down Expand Up @@ -204,6 +207,7 @@ func serve(args []string) error {
adminToken := flags.String("admin-token", "", "admin token required for non-loopback _subrouter endpoints; defaults to SUBROUTER_ADMIN_TOKEN")
maxBodyBytes := flags.Int64("max-body-bytes", 1<<20, "max JSON request body bytes to inspect for session IDs")
fetchUsage := flags.Bool("fetch-usage", true, "fetch Codex usage on startup for account selection")
multiTenant := flags.Bool("multi-tenant", false, "reject unknown srt_ tenant keys even before the first tenant exists; tenant routing itself activates automatically once tenants exist")
bedrockEnable := flags.Bool("bedrock", false, "enable the /bedrock/* AWS SigV4 signing gateway for Claude Code Bedrock mode")
bedrockRegion := flags.String("bedrock-region", "us-east-1", "comma-separated AWS regions for the Bedrock signing gateway")
bedrockGatewayToken := flags.String("bedrock-gateway-token", "", "optional bearer token clients must present to the Bedrock gateway; defaults to SUBROUTER_BEDROCK_GATEWAY_TOKEN")
Expand Down Expand Up @@ -412,9 +416,15 @@ func serve(args []string) error {
slog.Info("sr auto-switch disabled because usage fetching is disabled", "interval", srSwitchInterval.String())
}

multiTenantHandler := &proxy.MultiTenant{
Base: server,
Registry: tenant.NewRegistry(storepath.StateDir()),
TranscriptDir: *transcriptDir,
Enabled: *multiTenant,
}
httpServer := &http.Server{
Addr: *addr,
Handler: server.Handler(),
Handler: multiTenantHandler.Handler(server.Handler()),
ReadHeaderTimeout: 10 * time.Second,
// Bound how long an idle client connection can pin this worker. The
// supervisor's drain waits for a retired generation's connections to
Expand Down Expand Up @@ -1027,6 +1037,11 @@ Usage:
%[1]s server login <name> [--device-auth]
%[1]s server sync <name> [--device-auth] [--yes]

%[1]s tenant create <name> [--server <name>] Create an isolated per-tenant account pool
%[1]s tenant list [--server <name>]
%[1]s tenant key create <tenant> [--server <name>]
%[1]s tenant key revoke <tenant> <key-prefix> [--server <name>]

%[1]s admin-keys List stored OpenAI admin keys
%[1]s add-admin-key Add an sk-admin-* key
%[1]s remove-admin-key <label>
Expand All @@ -1040,7 +1055,7 @@ Usage:
%[1]s spend Show AWS Bedrock spend tracked by the server
%[1]s gemini Manage Gemini profiles

%[1]s serve [--addr 127.0.0.1:31415] [--fetch-usage=true] [--codex-upstream URL] [--claude-upstream URL] [--kimi-upstream URL] [--zai-upstream URL] [--transcripts DIR] [--transcript-gcs-uri gs://bucket/prefix] [--transcript-gcs-sync-timeout 30m] [--transcript-local-retention 24h] [--transcript-max-local-bytes 2GiB]
%[1]s serve [--addr 127.0.0.1:31415] [--fetch-usage=true] [--multi-tenant] [--codex-upstream URL] [--claude-upstream URL] [--kimi-upstream URL] [--zai-upstream URL] [--transcripts DIR] [--transcript-gcs-uri gs://bucket/prefix] [--transcript-gcs-sync-timeout 30m] [--transcript-local-retention 24h] [--transcript-max-local-bytes 2GiB]
%[1]s supervise --worker-bin PATH [--addr 127.0.0.1:31415] [--control-socket /var/run/subrouter-supervisor.sock] [--drain-timeout 10m] -- [serve flags]
%[1]s accounts
%[1]s codex [codex args...]
Expand Down
2 changes: 2 additions & 0 deletions cmd/subrouter/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ func TestDirectSRCommandNames(t *testing.T) {
"status",
"storage",
"switch",
"tenant",
"tenants",
"team",
"trace",
"usage",
Expand Down
12 changes: 11 additions & 1 deletion cmd/subrouter/sr.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ Running agents:
sr server login <name> [--device-auth]
sr server sync <name> [--device-auth] [--yes]

sr tenant create <name> [--server <name>]
sr tenant list [--server <name>]
sr tenant key create <tenant> [--server <name>]
sr tenant key revoke <tenant> <key-prefix> [--server <name>]

sr admin-keys List stored OpenAI admin keys
sr add-admin-key Add an sk-admin-* key
sr remove-admin-key <label>
Expand Down Expand Up @@ -289,6 +294,11 @@ func (r srRunner) run(ctx context.Context, args []string) error {
return r.attachProject(ctx, args[1], projectID)
case "server", "servers":
return r.server(ctx, args[1:])
case "tenant", "tenants":
return r.tenant(ctx, args[1:])
case "help", "-h", "--help":
fmt.Fprint(r.out, srHelp)
return nil
case "daemon":
return runDaemonCommand(ctx, args[1:], r.out, r.errOut)
case "setup":
Expand All @@ -313,7 +323,7 @@ func (r srRunner) run(ctx context.Context, args []string) error {

func shouldRouteSRCommand(command string) bool {
switch command {
case "server", "servers", "claude", "claude-aws", "claude-direct", "spend", "cost", "gemini", "help", "-h", "--help":
case "server", "servers", "tenant", "tenants", "claude", "claude-aws", "claude-direct", "spend", "cost", "gemini", "help", "-h", "--help":
return false
// Setup, cleanup and doctor act on this machine, never the remote server.
case "setup", "cleanup", "daemon", "doctor", "login", "logout", "team", "account", "accounts", "storage":
Expand Down
50 changes: 31 additions & 19 deletions cmd/subrouter/sr_claude_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/manaflow-ai/subrouter/internal/agents/claude"
"github.com/manaflow-ai/subrouter/internal/broker"
"github.com/manaflow-ai/subrouter/internal/tenant"
)

// pushClaudeProfileToServer uploads a local Claude profile's credential to the
Expand Down Expand Up @@ -83,14 +84,18 @@ func (r srRunner) pushClaudeProfile(ctx context.Context, name string, requireSer
if err := r.uploadServerClaudeProfile(ctx, server, store, profile, *credential); err != nil {
return err
}
if err := writeClaudeProxyEnv(configDir, server.URL); err != nil {
if err := writeClaudeProxyEnv(configDir, serverProxyRootURL(server), strings.TrimSpace(server.TenantKey)); err != nil {
return fmt.Errorf("profile uploaded, but writing proxy env to settings.json failed: %w", err)
}
fmt.Fprintf(r.out, "Uploaded Claude profile %s to server %s and switched local runs to the server pool.\n", profile.Name, server.Name)
return nil
}

func (r srRunner) uploadServerClaudeProfile(ctx context.Context, server srServerConfig, store claude.Store, profile claude.Profile, credential claude.CredentialInfo) error {
stateSubdir, err := r.serverStateSubdir(ctx, server)
if err != nil {
return err
}
dir := filepath.Base(store.InstancePath(profile.Name))
if dir == "" || dir == "." || dir == string(os.PathSeparator) {
return fmt.Errorf("could not determine instance dir for profile %q", profile.Name)
Expand All @@ -100,15 +105,15 @@ func (r srRunner) uploadServerClaudeProfile(ctx context.Context, server srServer
return err
}
body = append(body, '\n')
archive, err := buildClaudeCredentialArchive(dir, body)
archive, err := buildClaudeCredentialArchive(stateSubdir, dir, body)
if err != nil {
return err
}
host := sshHostForServer(server)
if host == "" {
return fmt.Errorf("server %s has no SSH-able host in its URL", server.Name)
}
remoteCommand := claudeUploadRemoteCommand(server, profile.Name, dir)
remoteCommand := claudeUploadRemoteCommand(server, stateSubdir, profile.Name, dir)
args := []string{
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=15",
Expand Down Expand Up @@ -143,11 +148,11 @@ func (r srRunner) uploadServerClaudeProfile(ctx context.Context, server srServer
return fmt.Errorf("upload claude profile over ssh: %w", lastErr)
}

func buildClaudeCredentialArchive(dir string, credentialJSON []byte) ([]byte, error) {
func buildClaudeCredentialArchive(stateSubdir, dir string, credentialJSON []byte) ([]byte, error) {
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
tw := tar.NewWriter(gz)
relPath := filepath.Join("codex", "claude", dir, ".credentials.json")
relPath := filepath.Join(stateSubdir, "codex", "claude", dir, ".credentials.json")
if err := tw.WriteHeader(&tar.Header{Name: relPath, Mode: 0o600, Size: int64(len(credentialJSON))}); err != nil {
return nil, err
}
Expand All @@ -163,38 +168,40 @@ func buildClaudeCredentialArchive(dir string, credentialJSON []byte) ([]byte, er
return archive.Bytes(), nil
}

func claudeUploadRemoteCommand(server srServerConfig, profileName, dir string) string {
func claudeUploadRemoteCommand(server srServerConfig, stateSubdir, profileName, dir string) string {
createdAt := time.Now().UTC().Format(time.RFC3339)
// The registration merge keeps any existing createdAt and other profiles
// intact; only this profile's name/dir are overwritten.
jqProgram := `.profiles[$name] = {name: $name, createdAt: (.profiles[$name].createdAt // $created), dir: $dir}`
remotePath := fmt.Sprintf("/tmp/sr-claude-cred-%d.tgz", time.Now().UnixNano())
registryPath := "/var/lib/subrouter/codex/claude.json"
claudeDir := remoteStatePath(stateSubdir, "codex/claude")
registryPath := remoteStatePath(stateSubdir, "codex/claude.json")
return strings.Join([]string{
"set -euo pipefail",
"cat > " + shellQuote(remotePath),
"sr_owner=subrouter; sr_group=subrouter; if [ -e /var/lib/subrouter ]; then sr_owner=$(stat -f '%Su' /var/lib/subrouter 2>/dev/null || stat -c '%U' /var/lib/subrouter); sr_group=$(stat -f '%Sg' /var/lib/subrouter 2>/dev/null || stat -c '%G' /var/lib/subrouter); elif id -u _subrouter >/dev/null 2>&1; then sr_owner=_subrouter; sr_group=_subrouter; fi",
"reload_status=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:31415/_subrouter/reload-accounts || true)",
"if [ \"$reload_status\" != \"405\" ]; then echo " + shellQuote("Subrouter server is too old for hot account reload; run sr server install "+server.Name+" first.") + " >&2; exit 1; fi",
"command -v jq >/dev/null || { echo 'jq is required on the server for claude profile registration' >&2; exit 1; }",
"sudo install -d -o \"$sr_owner\" -g \"$sr_group\" -m 0700 /var/lib/subrouter/codex/claude",
"sudo install -d -o \"$sr_owner\" -g \"$sr_group\" -m 0700 " + shellQuote(claudeDir),
"sudo tar -C /var/lib/subrouter -xzf " + shellQuote(remotePath),
"sudo rm -f " + shellQuote(remotePath),
"sudo chmod 700 " + shellQuote("/var/lib/subrouter/codex/claude/"+dir),
"sudo chmod 600 " + shellQuote("/var/lib/subrouter/codex/claude/"+dir+"/.credentials.json"),
"sudo chmod 700 " + shellQuote(claudeDir+"/"+dir),
"sudo chmod 600 " + shellQuote(claudeDir+"/"+dir+"/.credentials.json"),
"sudo sh -c " + shellQuote("test -s "+registryPath+" || printf '{\"profiles\":{}}' > "+registryPath),
"sudo jq --arg name " + shellQuote(profileName) + " --arg dir " + shellQuote(dir) + " --arg created " + shellQuote(createdAt) + " " + shellQuote(jqProgram) + " " + registryPath + " | sudo tee " + registryPath + ".new >/dev/null",
"sudo mv " + registryPath + ".new " + registryPath,
"sudo chown -R \"$sr_owner:$sr_group\" /var/lib/subrouter/codex/claude " + registryPath,
"sudo jq --arg name " + shellQuote(profileName) + " --arg dir " + shellQuote(dir) + " --arg created " + shellQuote(createdAt) + " " + shellQuote(jqProgram) + " " + shellQuote(registryPath) + " | sudo tee " + shellQuote(registryPath+".new") + " >/dev/null",
"sudo mv " + shellQuote(registryPath+".new") + " " + shellQuote(registryPath),
"sudo chown -R \"$sr_owner:$sr_group\" " + shellQuote(claudeDir) + " " + shellQuote(registryPath),
"curl -fsS -X POST http://127.0.0.1:31415/_subrouter/reload-accounts >/dev/null",
}, " && ")
}

// writeClaudeProxyEnv merges the Subrouter proxy env into the profile's
// settings.json, preserving unrelated settings. The dummy auth token satisfies
// Claude Code's auth requirement; the server replaces it with pooled
// credentials.
func writeClaudeProxyEnv(configDir, serverURL string) error {
// settings.json, preserving unrelated settings. With no tenant key, a dummy
// auth token satisfies Claude Code's auth requirement and the server replaces
// it with pooled credentials; with a tenant key, the key itself is the auth
// token so the server can scope the request to the tenant's pool.
func writeClaudeProxyEnv(configDir, baseURL, tenantKey string) error {
settingsPath := filepath.Join(configDir, "settings.json")
settings := map[string]any{}
if body, err := os.ReadFile(settingsPath); err == nil {
Expand All @@ -206,8 +213,13 @@ func writeClaudeProxyEnv(configDir, serverURL string) error {
if env == nil {
env = map[string]any{}
}
env["ANTHROPIC_BASE_URL"] = strings.TrimRight(serverURL, "/")
if _, ok := env["ANTHROPIC_AUTH_TOKEN"]; !ok {
env["ANTHROPIC_BASE_URL"] = strings.TrimRight(baseURL, "/")
if tenantKey != "" {
env["ANTHROPIC_AUTH_TOKEN"] = tenantKey
} else if existing, ok := env["ANTHROPIC_AUTH_TOKEN"].(string); !ok || tenant.ValidKeyFormat(existing) {
// Absent, or a stale tenant key left over from a previous
// tenant-scoped server: reset to the dummy token. Unrelated custom
// tokens are preserved.
env["ANTHROPIC_AUTH_TOKEN"] = "subrouter"
}
settings["env"] = env
Expand Down
10 changes: 5 additions & 5 deletions cmd/subrouter/sr_claude_upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

func TestBuildClaudeCredentialArchive(t *testing.T) {
payload := []byte(`{"claudeAiOauth":{"accessToken":"tok"}}`)
archive, err := buildClaudeCredentialArchive("_p123", payload)
archive, err := buildClaudeCredentialArchive("", "_p123", payload)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -43,7 +43,7 @@ func TestBuildClaudeCredentialArchive(t *testing.T) {
}

func TestClaudeUploadRemoteCommand(t *testing.T) {
command := claudeUploadRemoteCommand(srServerConfig{Name: "team", URL: "http://subrouter-team:31415"}, "lc9@example.com", "_p999")
command := claudeUploadRemoteCommand(srServerConfig{Name: "team", URL: "http://subrouter-team:31415"}, "", "lc9@example.com", "_p999")
for _, want := range []string{
"sudo tar -C /var/lib/subrouter",
"/var/lib/subrouter/codex/claude.json",
Expand All @@ -70,7 +70,7 @@ func TestWriteClaudeProxyEnvMergesSettings(t *testing.T) {
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark","env":{"FOO":"bar"}}`), 0o600); err != nil {
t.Fatal(err)
}
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415/"); err != nil {
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415/", ""); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(settingsPath)
Expand Down Expand Up @@ -101,7 +101,7 @@ func TestWriteClaudeProxyEnvMergesSettings(t *testing.T) {
if err := os.WriteFile(settingsPath, body, 0o600); err != nil {
t.Fatal(err)
}
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415"); err != nil {
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415", ""); err != nil {
t.Fatal(err)
}
body, _ = os.ReadFile(settingsPath)
Expand All @@ -115,7 +115,7 @@ func TestWriteClaudeProxyEnvMergesSettings(t *testing.T) {

func TestWriteClaudeProxyEnvCreatesSettings(t *testing.T) {
dir := filepath.Join(t.TempDir(), "fresh")
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415"); err != nil {
if err := writeClaudeProxyEnv(dir, "http://subrouter-team:31415", ""); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, "settings.json"))
Expand Down
Loading