Skip to content

Commit f92ad28

Browse files
committed
api: unify backend auto-switch across chat/responses/claude/code-review
1 parent 6ffe44e commit f92ad28

3 files changed

Lines changed: 141 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ The format follows Keep a Changelog and uses semantic version tags (`vMAJOR.MINO
2424
- Installer terminal output now uses clearer colored status lines for info, success, and error messages.
2525
- README now documents `CODEXSESS_BIND_ADDR` and includes GUI-mode `~/.bashrc` bind override example for `0.0.0.0`.
2626
- Startup logging now prints actual bind address separately from local browser URL so public bind mode is explicit in runtime logs.
27+
- Startup logging now adds explicit `public bind enabled` line when bind host is `0.0.0.0` or `::`.
28+
- API request routing now applies backend auto-switch consistently across `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, and `/v1/code-review`: if active account quota is exhausted, it switches to the best available account; if all are exhausted, it returns quota exhaustion.
2729

2830
## [1.0.1] - 2026-03-18
2931

internal/httpapi/server.go

Lines changed: 127 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,93 @@ func ptrString(v string) *string {
190190
return &v
191191
}
192192

193+
func (s *Server) resolveAPIAccount(ctx context.Context, selector string) (store.Account, error) {
194+
account, _, err := s.svc.ResolveForRequest(ctx, selector)
195+
if err != nil {
196+
return store.Account{}, err
197+
}
198+
199+
usage, usageErr := s.loadOrRefreshUsage(ctx, account.ID)
200+
if usageErr != nil {
201+
// If usage check fails, keep current behavior and proceed with active account.
202+
return account, nil
203+
}
204+
if usageAvailable(usage) {
205+
return account, nil
206+
}
207+
208+
// Explicit selector should stay strict and not auto-switch.
209+
if strings.TrimSpace(selector) != "" {
210+
return store.Account{}, fmt.Errorf("target account quota exhausted")
211+
}
212+
213+
best, ok := s.findBestUsageAccount(ctx, account.ID)
214+
if !ok {
215+
return store.Account{}, fmt.Errorf("all API accounts are exhausted")
216+
}
217+
switched, err := s.svc.UseAccountAPI(ctx, best.ID)
218+
if err != nil {
219+
return store.Account{}, err
220+
}
221+
return switched, nil
222+
}
223+
224+
func (s *Server) loadOrRefreshUsage(ctx context.Context, accountID string) (store.UsageSnapshot, error) {
225+
usage, err := s.svc.Store.GetUsage(ctx, accountID)
226+
if err == nil {
227+
return usage, nil
228+
}
229+
return s.svc.RefreshUsage(ctx, accountID)
230+
}
231+
232+
func usageAvailable(u store.UsageSnapshot) bool {
233+
return u.HourlyPct > 0 && u.WeeklyPct > 0
234+
}
235+
236+
func usageScore(u store.UsageSnapshot) int {
237+
if u.HourlyPct < u.WeeklyPct {
238+
return u.HourlyPct
239+
}
240+
return u.WeeklyPct
241+
}
242+
243+
func (s *Server) findBestUsageAccount(ctx context.Context, skipID string) (store.Account, bool) {
244+
accounts, err := s.svc.ListAccounts(ctx)
245+
if err != nil || len(accounts) == 0 {
246+
return store.Account{}, false
247+
}
248+
249+
usageMap, _ := s.svc.Store.ListUsageSnapshots(ctx)
250+
var best store.Account
251+
bestScore := -1
252+
found := false
253+
254+
for _, a := range accounts {
255+
if strings.TrimSpace(a.ID) == "" || a.ID == skipID {
256+
continue
257+
}
258+
u, ok := usageMap[a.ID]
259+
if !ok || strings.TrimSpace(u.LastError) != "" {
260+
refreshed, err := s.svc.RefreshUsage(ctx, a.ID)
261+
if err != nil {
262+
continue
263+
}
264+
u = refreshed
265+
}
266+
if !usageAvailable(u) {
267+
continue
268+
}
269+
score := usageScore(u)
270+
if score > bestScore {
271+
best = a
272+
bestScore = score
273+
found = true
274+
}
275+
}
276+
277+
return best, found
278+
}
279+
193280
type accessLogRecorder struct {
194281
http.ResponseWriter
195282
status int
@@ -932,25 +1019,20 @@ func (s *Server) handleCodeReview(w http.ResponseWriter, r *http.Request) {
9321019
return
9331020
}
9341021

935-
account, _, err := s.svc.ResolveForRequest(r.Context(), "")
1022+
account, err := s.resolveAPIAccount(r.Context(), "")
9361023
if err != nil {
937-
respondErr(w, 404, "account_not_found", err.Error())
938-
return
939-
}
940-
setResolvedAccountHeaders(w, account)
941-
usage, usageErr := s.svc.Store.GetUsage(r.Context(), account.ID)
942-
if usageErr != nil {
943-
if snap, err := s.svc.RefreshUsage(r.Context(), account.ID); err == nil {
944-
usage = snap
945-
usageErr = nil
946-
}
947-
}
948-
if usageErr == nil {
949-
if usage.HourlyPct <= 0 || usage.WeeklyPct <= 0 {
1024+
msg := strings.ToLower(strings.TrimSpace(err.Error()))
1025+
switch {
1026+
case strings.Contains(msg, "not found"):
1027+
respondErr(w, 404, "account_not_found", err.Error())
1028+
case strings.Contains(msg, "exhausted"):
9501029
respondErr(w, 429, "quota_exhausted", "target account quota exhausted")
951-
return
1030+
default:
1031+
respondErr(w, 500, "internal_error", err.Error())
9521032
}
1033+
return
9531034
}
1035+
setResolvedAccountHeaders(w, account)
9541036

9551037
status := 200
9561038
defer func() {
@@ -1055,25 +1137,20 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
10551137
}
10561138
req.Model = s.resolveMappedModel(req.Model)
10571139
prompt := promptFromMessagesWithTools(req.Messages, req.Tools, req.ToolChoice)
1058-
account, _, err := s.svc.ResolveForRequest(r.Context(), selector)
1140+
account, err := s.resolveAPIAccount(r.Context(), selector)
10591141
if err != nil {
1060-
respondErr(w, 404, "account_not_found", err.Error())
1061-
return
1062-
}
1063-
setResolvedAccountHeaders(w, account)
1064-
usage, usageErr := s.svc.Store.GetUsage(r.Context(), account.ID)
1065-
if usageErr != nil {
1066-
if snap, err := s.svc.RefreshUsage(r.Context(), account.ID); err == nil {
1067-
usage = snap
1068-
usageErr = nil
1069-
}
1070-
}
1071-
if usageErr == nil {
1072-
if usage.HourlyPct <= 0 || usage.WeeklyPct <= 0 {
1142+
msg := strings.ToLower(strings.TrimSpace(err.Error()))
1143+
switch {
1144+
case strings.Contains(msg, "not found"):
1145+
respondErr(w, 404, "account_not_found", err.Error())
1146+
case strings.Contains(msg, "exhausted"):
10731147
respondErr(w, 429, "quota_exhausted", "target account quota exhausted")
1074-
return
1148+
default:
1149+
respondErr(w, 500, "internal_error", err.Error())
10751150
}
1151+
return
10761152
}
1153+
setResolvedAccountHeaders(w, account)
10771154
status := 200
10781155
defer func() {
10791156
_ = s.svc.Store.InsertAudit(r.Context(), store.AuditRecord{
@@ -1188,25 +1265,20 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
11881265
respondErr(w, 400, "bad_request", "input is required")
11891266
return
11901267
}
1191-
account, _, err := s.svc.ResolveForRequest(r.Context(), selector)
1268+
account, err := s.resolveAPIAccount(r.Context(), selector)
11921269
if err != nil {
1193-
respondErr(w, 404, "account_not_found", err.Error())
1194-
return
1195-
}
1196-
setResolvedAccountHeaders(w, account)
1197-
usage, usageErr := s.svc.Store.GetUsage(r.Context(), account.ID)
1198-
if usageErr != nil {
1199-
if snap, err := s.svc.RefreshUsage(r.Context(), account.ID); err == nil {
1200-
usage = snap
1201-
usageErr = nil
1202-
}
1203-
}
1204-
if usageErr == nil {
1205-
if usage.HourlyPct <= 0 || usage.WeeklyPct <= 0 {
1270+
msg := strings.ToLower(strings.TrimSpace(err.Error()))
1271+
switch {
1272+
case strings.Contains(msg, "not found"):
1273+
respondErr(w, 404, "account_not_found", err.Error())
1274+
case strings.Contains(msg, "exhausted"):
12061275
respondErr(w, 429, "quota_exhausted", "target account quota exhausted")
1207-
return
1276+
default:
1277+
respondErr(w, 500, "internal_error", err.Error())
12081278
}
1279+
return
12091280
}
1281+
setResolvedAccountHeaders(w, account)
12101282
status := 200
12111283
defer func() {
12121284
_ = s.svc.Store.InsertAudit(r.Context(), store.AuditRecord{
@@ -1351,25 +1423,20 @@ func (s *Server) handleClaudeMessages(w http.ResponseWriter, r *http.Request) {
13511423
respondErr(w, 400, "bad_request", "messages are required")
13521424
return
13531425
}
1354-
account, _, err := s.svc.ResolveForRequest(r.Context(), selector)
1426+
account, err := s.resolveAPIAccount(r.Context(), selector)
13551427
if err != nil {
1356-
respondErr(w, 404, "account_not_found", err.Error())
1357-
return
1358-
}
1359-
setResolvedAccountHeaders(w, account)
1360-
usage, usageErr := s.svc.Store.GetUsage(r.Context(), account.ID)
1361-
if usageErr != nil {
1362-
if snap, err := s.svc.RefreshUsage(r.Context(), account.ID); err == nil {
1363-
usage = snap
1364-
usageErr = nil
1365-
}
1366-
}
1367-
if usageErr == nil {
1368-
if usage.HourlyPct <= 0 || usage.WeeklyPct <= 0 {
1428+
msg := strings.ToLower(strings.TrimSpace(err.Error()))
1429+
switch {
1430+
case strings.Contains(msg, "not found"):
1431+
respondErr(w, 404, "account_not_found", err.Error())
1432+
case strings.Contains(msg, "exhausted"):
13691433
respondErr(w, 429, "quota_exhausted", "target account quota exhausted")
1370-
return
1434+
default:
1435+
respondErr(w, 500, "internal_error", err.Error())
13711436
}
1437+
return
13721438
}
1439+
setResolvedAccountHeaders(w, account)
13731440
status := 200
13741441
defer func() {
13751442
_ = s.svc.Store.InsertAudit(r.Context(), store.AuditRecord{

main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ func run() error {
9292

9393
appURL := localAppURL(cfg.BindAddr)
9494
log.Printf("codexsess bind address %s", strings.TrimSpace(cfg.BindAddr))
95+
if bindIsPublic(cfg.BindAddr) {
96+
log.Printf("codexsess public bind enabled (accessible via host IP/network): %s", strings.TrimSpace(cfg.BindAddr))
97+
}
9598
log.Printf("codexsess local console %s", appURL)
9699
if shouldAutoOpenBrowser() {
97100
go waitAndOpenBrowser(appURL)
@@ -138,6 +141,15 @@ func localAppURL(bindAddr string) string {
138141
return fmt.Sprintf("http://%s:%s", h, port)
139142
}
140143

144+
func bindIsPublic(bindAddr string) bool {
145+
host, _, err := net.SplitHostPort(strings.TrimSpace(bindAddr))
146+
if err != nil {
147+
return false
148+
}
149+
h := strings.Trim(strings.TrimSpace(host), "[]")
150+
return h == "0.0.0.0" || h == "::"
151+
}
152+
141153
func waitAndOpenBrowser(appURL string) {
142154
healthURL := strings.TrimRight(appURL, "/") + "/healthz"
143155
client := &http.Client{Timeout: 700 * time.Millisecond}

0 commit comments

Comments
 (0)