Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/northbrook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Northbrook tests and build

on:
push:
branches: [northbrook]
pull_request:
branches: [northbrook]
workflow_dispatch:

permissions:
contents: read

jobs:
test-build:
if: github.repository == 'north-brook/CLIProxyAPI'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Test
run: go test ./...
- name: Build
run: go build -o test-output ./cmd/server && rm test-output
87 changes: 87 additions & 0 deletions NORTHBROOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Northbrook quota-aware routing

This fork adds a standalone Claude OAuth routing strategy on top of upstream
CLIProxyAPI v8.0.43. Codex WebSockets and the other existing transports are preserved.

```yaml
routing:
strategy: quota-aware
session-affinity: true
session-affinity-ttl: 24h
session-affinity-subagents: true
quota-aware:
refresh-interval: 5m
max-age: 15m
reserves:
- provider: claude
account: personal@example.com
percent: 20
```

Account matching uses the OAuth credential's email, case insensitively. No
credentials or personal account configuration belong in this repository.

## Selection

For each eligible Claude OAuth account, each applicable quota window contributes:

```
spend rate = (remaining percentage - reserved percentage) / seconds until reset
account score = minimum spend rate across its applicable windows
```

The highest score wins a new binding. This favors allowance that can be spent
faster before it expires, while respecting the most restrictive quota window.
Five-hour and shared weekly windows are required for a protected account;
reported Opus/Sonnet weekly windows also apply when routing that model family.
An unused zero-utilization window without a reset is treated as a full-duration
window until the provider reports its actual first-request reset.

Fixed account priorities only break equal scores or provide a fallback when no
unprotected account has fresh, complete quota data. Other providers and Claude
API keys retain priority-based round-robin behavior. A healthy session keeps its
existing account even when another account's score rises.

## Reserves and freshness

A request cannot select an account at or below its reserve in any applicable
window. This check precedes explicit and inferred session affinity, subagent
inheritance, pinned account selection, and retries. When necessary, a session
moves to another eligible account. If none is eligible, the proxy returns a
`quota_reserve` error rather than consuming reserved allowance.

Protected accounts with missing, invalid, expired, or stale quota data are excluded.
Unprotected accounts with unknown quota remain available as a fallback. Existing
provider cooldowns, model availability, and disabled-account rules still apply.
Reserves require the quota-aware strategy; invalid configuration is rejected on
startup and reload. Home dispatch is not supported because it owns account
selection outside this process.

Quota headers from ordinary Claude responses update the existing passive quota
snapshot. A background task also reads the authenticated Claude usage endpoint
every five minutes by default, covering accounts used outside the proxy. HTTP 429
probes back off for at least fifteen minutes and honor a longer Retry-After.
There are no usage probes on the inference request path. Probe results cannot
overwrite a newer response snapshot or a replaced credential.

The reserve is an admission threshold based on provider observations, not a
guarantee of an exact remaining balance: already-running requests, delayed
provider accounting, and simultaneous Claude app usage can cross it. Set a larger
reserve if additional margin is needed. The proxy does not cancel running work.

Configuration changes use the existing file watcher or v8 management config API.
No management UI changes are required. The existing auth-file quota observations
expose the signals used by the router.

## Build and verify

Use Go 1.26.10 or newer and the repository's normal recipes:

```sh
go test ./...
go test -race ./sdk/cliproxy/auth ./sdk/cliproxy -run 'TestQuota|TestClaudeUsageHeaders'
go build -o cli-proxy-api ./cmd/server
```

On macOS, point DEVELOPER_DIR and SDKROOT at a matching installed Xcode toolchain
if the default compiler cannot read the selected SDK's text-based libraries.
12 changes: 11 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,17 @@ access:

# Routing strategy for selecting credentials when multiple match.
routing:
strategy: "round-robin" # round-robin (default), weighted-round-robin, fill-first
strategy: "round-robin" # round-robin (default), weighted-round-robin, fill-first, quota-aware
# Northbrook: quota-aware ranks Claude OAuth accounts using spendable quota
# and time until reset. Reserves override session affinity and fixed priority.
# Other providers retain priority-based round-robin. Standalone mode only.
# quota-aware:
# refresh-interval: "5m" # background idle-account usage probes
# max-age: "15m" # protected accounts require fresh quota data
# reserves:
# - provider: "claude"
# account: "personal@example.com" # exact OAuth email, case insensitive
# percent: 20 # retain in every applicable window
# weighted-round-robin uses each credential's integer weight (default 1, maximum 1,000,000).
# Non-positive weights exclude the credential while this strategy is active.
# For OAuth/file credentials, add a top-level numeric "weight" field to the auth JSON.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if errValidate := cfg.Codex.LiveMediaRelay.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateQuotaRouting(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateCredentialWeights(); errValidate != nil {
return nil, errValidate
}
Expand Down
5 changes: 4 additions & 1 deletion internal/config/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,12 @@ type QuotaExceeded struct {
// RoutingConfig configures how credentials are selected for requests.
type RoutingConfig struct {
// Strategy selects the credential selection strategy.
// Supported values: "round-robin" (default), "weighted-round-robin", "fill-first".
// Supported values: "round-robin" (default), "weighted-round-robin", "fill-first", "quota-aware".
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`

// QuotaAware controls Claude OAuth quota routing and per-account reserves.
QuotaAware QuotaAwareRoutingConfig `yaml:"quota-aware,omitempty" json:"quota-aware,omitempty"`

// SessionAffinity enables universal session-sticky routing for all clients.
// Explicit Claude Code, Codex, OpenCode, and pi session headers are preferred,
// followed by prompt_cache_key, Responses conversation IDs, legacy body IDs,
Expand Down
3 changes: 3 additions & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func ParseConfigBytes(data []byte) (*Config, error) {
if errValidate := cfg.CredentialInFlight.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateQuotaRouting(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateCredentialWeights(); errValidate != nil {
return nil, errValidate
}
Expand Down
79 changes: 79 additions & 0 deletions internal/config/quota_routing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package config

import (
"fmt"
"math"
"strings"
"time"
)

// QuotaAwareRoutingConfig applies only when routing.strategy is quota-aware.
type QuotaAwareRoutingConfig struct {
RefreshInterval string `yaml:"refresh-interval,omitempty" json:"refresh-interval,omitempty"`
MaxAge string `yaml:"max-age,omitempty" json:"max-age,omitempty"`
Reserves []QuotaReserve `yaml:"reserves,omitempty" json:"reserves,omitempty"`
}

// QuotaReserve identifies an OAuth account by provider and email, not filename.
type QuotaReserve struct {
Provider string `yaml:"provider" json:"provider"`
Account string `yaml:"account" json:"account"`
Percent float64 `yaml:"percent" json:"percent"`
}

func (q QuotaAwareRoutingConfig) RefreshDuration() time.Duration {
if d, err := time.ParseDuration(q.RefreshInterval); err == nil && d > 0 {
return d
}
return 5 * time.Minute
}

func (q QuotaAwareRoutingConfig) MaxAgeDuration() time.Duration {
if d, err := time.ParseDuration(q.MaxAge); err == nil && d > 0 {
return d
}
return 15 * time.Minute
}

// ValidateQuotaRouting rejects typos and invalid reserves rather than silently
// disabling account protection. Other providers retain their existing routing.
func (cfg *Config) ValidateQuotaRouting() error {
if cfg == nil {
return nil
}
q := cfg.Routing.QuotaAware
quotaAware := strings.EqualFold(strings.TrimSpace(cfg.Routing.Strategy), "quota-aware")
if len(q.Reserves) > 0 && !quotaAware {
return fmt.Errorf("quota reserves require routing.strategy: quota-aware")
}
if quotaAware && cfg.Home.Enabled {
return fmt.Errorf("quota-aware routing is supported only in standalone mode, not Home dispatch")
}
for name, value := range map[string]string{"refresh-interval": q.RefreshInterval, "max-age": q.MaxAge} {
if value == "" {
continue
}
d, err := time.ParseDuration(value)
if err != nil || d < time.Second {
return fmt.Errorf("routing.quota-aware.%s must be a duration of at least 1s", name)
}
}
if q.MaxAgeDuration() < q.RefreshDuration() {
return fmt.Errorf("routing.quota-aware.max-age must be at least refresh-interval")
}
seen := map[string]bool{}
for _, r := range q.Reserves {
if r.Provider != "claude" || strings.TrimSpace(r.Account) == "" {
return fmt.Errorf("quota reserves require provider claude and a nonempty account email")
}
if math.IsNaN(r.Percent) || math.IsInf(r.Percent, 0) || r.Percent < 0 || r.Percent > 100 {
return fmt.Errorf("quota reserve percent must be between 0 and 100")
}
key := strings.ToLower(strings.TrimSpace(r.Account))
if seen[key] {
return fmt.Errorf("duplicate quota reserve for account %q", r.Account)
}
seen[key] = true
}
return nil
}
50 changes: 50 additions & 0 deletions internal/config/quota_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package config

import (
"math"
"testing"
"time"

"gopkg.in/yaml.v3"
)

func TestQuotaRoutingValidationAndRoundTrip(t *testing.T) {
good := &Config{Routing: RoutingConfig{Strategy: "quota-aware", QuotaAware: QuotaAwareRoutingConfig{Reserves: []QuotaReserve{{Provider: "claude", Account: "personal@example.com", Percent: 20}}}}}
if err := good.ValidateQuotaRouting(); err != nil {
t.Fatal(err)
}
if good.Routing.QuotaAware.RefreshDuration() != 5*time.Minute || good.Routing.QuotaAware.MaxAgeDuration() != 15*time.Minute {
t.Fatal("unexpected quota observation defaults")
}
raw, err := yaml.Marshal(good)
if err != nil {
t.Fatal(err)
}
var restored Config
if err = yaml.Unmarshal(raw, &restored); err != nil || restored.Routing.QuotaAware.Reserves[0].Percent != 20 {
t.Fatalf("reserve lost on config roundtrip: %v", err)
}
for _, change := range []func(*Config){
func(c *Config) { c.Routing.Strategy = "round-robin" },
func(c *Config) { c.Home.Enabled = true },
func(c *Config) { c.Routing.QuotaAware.Reserves[0].Percent = -1 },
func(c *Config) { c.Routing.QuotaAware.Reserves[0].Percent = 101 },
func(c *Config) { c.Routing.QuotaAware.Reserves[0].Percent = math.NaN() },
func(c *Config) { c.Routing.QuotaAware.Reserves[0].Provider = "codex" },
func(c *Config) { c.Routing.QuotaAware.Reserves[0].Account = "" },
func(c *Config) { c.Routing.QuotaAware.RefreshInterval = "invalid" },
func(c *Config) { c.Routing.QuotaAware.MaxAge = "1m" },
func(c *Config) {
c.Routing.QuotaAware.Reserves = append(c.Routing.QuotaAware.Reserves, c.Routing.QuotaAware.Reserves[0])
},
} {
bad := good.CloneForRuntime()
change(bad)
if bad.ValidateQuotaRouting() == nil {
t.Fatalf("invalid quota config accepted: %+v", bad.Routing.QuotaAware)
}
}
if good.Routing.QuotaAware.Reserves[0].Percent != 20 {
t.Fatal("runtime clone shared reserve configuration")
}
}
10 changes: 6 additions & 4 deletions sdk/api/handlers/openai/openai_responses_interrupt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ func (e *homeInterruptExecutor) InterruptExecutionSession(ctx context.Context, s

type blockingHTTPInterruptExecutor struct {
calls atomic.Int32
canceled atomic.Bool
canceled chan struct{}
}

func (*blockingHTTPInterruptExecutor) Identifier() string { return "codex" }
Expand All @@ -292,7 +292,7 @@ func (e *blockingHTTPInterruptExecutor) ExecuteStream(ctx context.Context, _ *co
}
chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"type":"response.created","response":{"id":"r-http"}}`)}
<-ctx.Done()
e.canceled.Store(true)
close(e.canceled)
}()
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
Expand All @@ -317,7 +317,7 @@ func (*blockingHTTPInterruptExecutor) InterruptExecutionSession(context.Context,
// cancels an HTTP upstream turn and lets the same socket continue.
func TestResponsesInterruptStopsHTTPUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &blockingHTTPInterruptExecutor{}
executor := &blockingHTTPInterruptExecutor{canceled: make(chan struct{})}
manager := coreauth.NewManager(nil, nil, nil)
manager.SetConfig(&config.Config{})
manager.RegisterExecutor(executor)
Expand Down Expand Up @@ -372,7 +372,9 @@ func TestResponsesInterruptStopsHTTPUpstream(t *testing.T) {
if got := gjson.GetBytes(interrupted, "response.incomplete_details.reason").String(); got != "interrupted" {
t.Fatalf("interrupt reason = %q, payload %s", got, interrupted)
}
if !executor.canceled.Load() {
select {
case <-executor.canceled:
case <-time.After(5 * time.Second):
t.Fatal("http upstream was not canceled")
}
if errSend := client.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.create","model":%q,"previous_response_id":"r-http","input":[]}`, model))); errSend != nil {
Expand Down
16 changes: 16 additions & 0 deletions sdk/cliproxy/auth/conductor_selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,14 @@ func (m *Manager) availableAuthsForRouteModelWithPriorityMode(auths []*Auth, pro
// unless session affinity or an across-priorities scheduler is active, in which case the selector
// or scheduler additionally receives lower priority tiers.
func (m *Manager) availableAuthsForSelector(selector Selector, auths []*Auth, provider, routeModel string, now time.Time, schedulerAcross bool) (priorityAuths, selectorAuths []*Auth, err error) {
if quota := quotaSelector(selector); quota != nil {
all, errAvailable := m.availableAuthsForRouteModelAcrossPriorities(auths, provider, routeModel, now)
if errAvailable != nil {
return nil, nil, errAvailable
}
eligible, errQuota := quota.eligible(cloneAuthSlice(all), routeModel, now)
return eligible, eligible, errQuota
}
_, sessionAffinity := selector.(*SessionAffinitySelector)

if !sessionAffinity && !schedulerAcross {
Expand Down Expand Up @@ -694,6 +702,9 @@ func (m *Manager) transientlyUnavailableSessionAffinityAuthIDs(selector Selector
if candidate == nil || candidate.ID == "" {
continue
}
if quota := quotaSelector(selector); quota != nil && quota.budget(candidate, routeModel, now).blocked {
continue
}
checkModel := m.selectionModelForAuth(candidate, routeModel)
if !isAuthBlockedByQuotaOrAuthForModel(candidate, checkModel, now) {
if authIDs == nil {
Expand Down Expand Up @@ -1023,6 +1034,11 @@ func (m *Manager) pickViaPluginScheduler(ctx context.Context, scheduler PluginSc
if !okStrategy {
return nil, false, nil
}
// A built-in delegate selects from the scheduler's full pool. Keep quota
// routing on the filtered selector path so a delegate cannot spend reserves.
if quotaSelector(m.Selector()) != nil {
return nil, false, nil
}
return m.pickViaBuiltinScheduler(ctx, strategy, providerKey, providers, model, opts, tried)
}

Expand Down
Loading
Loading