diff --git a/PRODUCT.md b/PRODUCT.md index 1ca294d4..b9727358 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -107,6 +107,8 @@ Gateway admission allows model changes with reasoning, compaction and reconstruc Resource groups offer optional session stickiness, disabled by default. When enabled, requests that carry no gateway session derive a conversation key from client conversation headers, Codex turn metadata, Claude Code session metadata or, as a fallback, a hash of the system prompt and first user message, following CLIProxyAPI's session-affinity order. Derived keys create one-hour preferences in their own namespace, so they never read or rewrite an explicit binding: the preferred resource ranks first only after every policy, quota, allowance, cooldown and capacity check, and unavailable resources fall back to normal scheduling. Preferences may hold at most a quarter of the shared affinity table, are skipped rather than rejected when it is full, and are evicted first when an explicit conversation needs a slot. Subagents identified by Codex parent threads, Claude parent sessions or `X-Parent-Session-Id` prefer their parent's resource through read-only lookups of the parent's explicit binding or preference. Claude Code subagents share their main session key; a conversation keeps whichever raw or normalized key already holds its binding whether the setting is on or off; the setting only decides whether an unbound conversation creates a normalized binding. Explicit sessions keep the existing strict binding rules. +Resource groups also offer optional quota failover, disabled by default. When enabled, an existing Responses conversation whose bound account has exhausted its quota moves to another admitted account of the same provider instead of failing. The binding records 16-byte fingerprints (at most 4,096) of every encrypted reasoning or compaction item present at the move, and those items are withheld from the new account on that and every later turn, so only readable transcript and the new account's own state reach it. Requests that reference provider-held state (`previous_response_id`, `conversation`, `container` or `item_reference`) never move, and disablement, cooldown, saturation or model mismatch still never move a conversation. + API channels can be renamed and given a replacement key in place at `/channels`; leaving the key blank keeps the stored secret. Base URL, platform and protocol remain fixed so a stored key never reaches another upstream; those changes still require a separate channel. Edits retain the channel ID, group assignments, limits and request history; a key change resets verification and invalidates the model catalog in the same transaction. Resource groups can be deleted when no active personal key or resource allowance references them. Deletion retires the group: routing, grants, resources, model policy and bindings are removed in one audited transaction, while request history and usage aggregates keep their workspace scope. Retired groups keep their names; uniqueness applies only to active groups, so the name can be reused. Network proxies are shown as compact cards in a responsive grid whose column count follows the page width. Each card shows its status and latency, an IP purity panel, and the exit IP, location, network, verdict and bound accounts exactly once, merging the proxy check with the IP report. Opening the page only reads reports the server already holds; an administrator must explicitly request a lookup, which sends the exit IP observed by the last successful check, never the proxy endpoint, to the public ip.net.coffee lookup used by its IP scoring page. The panel shows its trust score with the same five purity tiers, network type, risk flags, ASN, ISP, location and verdict. Only public exit IPs are sent; results are cached per IP for six hours, concurrent lookups for one IP share a single request, attempts are paced to one per 30 seconds per IP from when they start, rate-limited responses back off for five minutes, and a new IP is refused while all 256 cache slots hold in-flight lookups. The report is informational and does not affect routing or proxy checks. diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 7432a3ad..2351853f 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -212,6 +212,8 @@ ALTER TABLE accounts DROP COLUMN reset_check_at; ALTER TABLE accounts DROP COLUMN usage_revision; ALTER TABLE account_affinity DROP COLUMN rebound; ALTER TABLE account_affinity DROP COLUMN model; +ALTER TABLE account_groups DROP COLUMN quota_failover; +ALTER TABLE account_affinity DROP COLUMN foreign_state; DROP INDEX account_groups_active_name; ALTER TABLE allocation_entries DROP COLUMN image_input_tokens; ALTER TABLE account_affinity DROP COLUMN derived; diff --git a/internal/gateway/failover.go b/internal/gateway/failover.go new file mode 100644 index 00000000..a66a8f0a --- /dev/null +++ b/internal/gateway/failover.go @@ -0,0 +1,150 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/json" +) + +// Quota failover lets a resource group move an existing Responses conversation off a +// quota-exhausted account. Encrypted reasoning and compaction state belongs to the account that +// produced it, so the binding remembers fingerprints of every encrypted item present when it moved +// and those items are withheld from the new account on this and later turns. + +const ( + stateDigestSize = 16 + // Bounds the fingerprint column (migration 035 allows 65,536 bytes). + foreignStateLimit = 4096 +) + +type stateDigest [stateDigestSize]byte + +// quotaFailover is owned by one Open call; selection fills Strip for the account it leased. +type quotaFailover struct { + // Movable is false when the request references provider-held state SubLane cannot replay. + Movable bool + // Present fingerprints encrypted items carried by the current request. + Present []stateDigest + // Strip lists fingerprints the leased account must not receive. + Strip map[stateDigest]bool +} + +type failoverKey struct{} + +func withQuotaFailover(ctx context.Context, carrier *quotaFailover) context.Context { + return context.WithValue(ctx, failoverKey{}, carrier) +} + +func quotaFailoverFrom(ctx context.Context) *quotaFailover { + carrier, _ := ctx.Value(failoverKey{}).(*quotaFailover) + return carrier +} + +func fingerprint(encrypted json.RawMessage) stateDigest { + // Hash the decoded token so equivalent JSON escaping yields one fingerprint. + value := []byte(encrypted) + var text string + if json.Unmarshal(encrypted, &text) == nil { + value = []byte(text) + } + sum := sha256.Sum256(value) + var digest stateDigest + copy(digest[:], sum[:]) + return digest +} + +type stateItem struct { + Type string `json:"type"` + EncryptedContent json.RawMessage `json:"encrypted_content"` +} + +func (item stateItem) encrypted() bool { + return len(item.EncryptedContent) > 0 && string(item.EncryptedContent) != "null" +} + +func newQuotaFailover(input map[string]json.RawMessage, kind Kind) *quotaFailover { + carrier := "aFailover{} + if kind != Responses && kind != Compact { + return carrier + } + for _, key := range []string{"previous_response_id", "conversation", "container"} { + if value := input[key]; len(value) > 0 && string(value) != "null" && string(value) != `""` { + return carrier + } + } + var items []stateItem + if json.Unmarshal(input["input"], &items) != nil { + // A string input carries no provider state. + var text string + carrier.Movable = json.Unmarshal(input["input"], &text) == nil || len(input["input"]) == 0 + return carrier + } + seen := map[stateDigest]bool{} + for _, item := range items { + // Stored item references live on the original account and cannot be replayed elsewhere. + if item.Type == "item_reference" { + return carrier + } + if item.encrypted() { + if digest := fingerprint(item.EncryptedContent); !seen[digest] { + seen[digest] = true + carrier.Present = append(carrier.Present, digest) + } + } + } + carrier.Movable = true + return carrier +} + +func decodeForeignState(raw []byte) map[stateDigest]bool { + set := make(map[stateDigest]bool, len(raw)/stateDigestSize) + for len(raw) >= stateDigestSize { + var digest stateDigest + copy(digest[:], raw[:stateDigestSize]) + set[digest] = true + raw = raw[stateDigestSize:] + } + return set +} + +// mergeForeignState returns the stored fingerprints plus the request's, or false past the bound. +func mergeForeignState(stored []byte, present []stateDigest) (map[stateDigest]bool, []byte, bool) { + set := decodeForeignState(stored) + encoded := append([]byte(nil), stored[:len(stored)-len(stored)%stateDigestSize]...) + for _, digest := range present { + if !set[digest] { + set[digest] = true + encoded = append(encoded, digest[:]...) + } + } + return set, encoded, len(set) <= foreignStateLimit +} + +// stripForeignState removes encrypted items another account produced. Visible messages and tool +// calls stay, so the new account continues from the readable transcript. +func stripForeignState(input map[string]json.RawMessage, strip map[stateDigest]bool) error { + if len(strip) == 0 { + return nil + } + var items []json.RawMessage + if json.Unmarshal(input["input"], &items) != nil { + return nil + } + kept := items[:0] + for _, raw := range items { + var item stateItem + if json.Unmarshal(raw, &item) == nil && item.encrypted() && strip[fingerprint(item.EncryptedContent)] { + continue + } + kept = append(kept, raw) + } + if len(kept) == len(items) { + return nil + } + encoded, err := json.Marshal(kept) + if err != nil { + return err + } + input["input"] = encoded + return nil +} diff --git a/internal/gateway/failover_test.go b/internal/gateway/failover_test.go new file mode 100644 index 00000000..638422bc --- /dev/null +++ b/internal/gateway/failover_test.go @@ -0,0 +1,136 @@ +package gateway + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/murongg/SubLane/internal/accounts" + "github.com/murongg/SubLane/internal/upstream" +) + +type capturedRequest struct{ account, body string } + +// failoverGateway returns the account a new conversation was bound to and the other pool member. +func failoverGateway(t *testing.T, enabled bool) (*Service, string, string, func() []capturedRequest) { + t.Helper() + var mu sync.Mutex + var seen []capturedRequest + s, ids := codexGateway(t, transportFunc(func(r *http.Request) (*http.Response, error) { + if r.Body != nil && strings.HasSuffix(r.URL.Path, "/responses") { + raw, _ := io.ReadAll(r.Body) + mu.Lock() + seen = append(seen, capturedRequest{r.Header.Get("Chatgpt-Account-Id"), string(raw)}) + mu.Unlock() + } + return syntheticStream(), nil + })) + ctx := context.Background() + second, err := s.accounts.Authorize(ctx, "Synthetic second", accounts.Credential{AccountID: "failover-second", AccessToken: "synthetic-access", RefreshToken: "synthetic-refresh", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "") + if err != nil { + t.Fatal(err) + } + if err := s.accounts.SaveCatalog(ctx, second.ID, 0, []string{"synthetic-model"}, s.now().Unix(), upstream.CatalogSource("codex")); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO group_accounts(group_id,account_id) VALUES(1,?)", second.ID); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("UPDATE account_groups SET quota_failover=? WHERE id=1", enabled); err != nil { + t.Fatal(err) + } + if err := openTurn(t, s, failoverUser); err != nil { + t.Fatal("initial binding", err) + } + first, other := boundAccount(t, s), second.ID + if first == second.ID { + other = ids["codex"] + } + return s, first, other, func() []capturedRequest { + mu.Lock() + defer mu.Unlock() + return append([]capturedRequest(nil), seen...) + } +} + +func openTurn(t *testing.T, s *Service, input string) error { + t.Helper() + response, err := s.Open(context.Background(), 1, 1, []byte(`{"model":"synthetic-model","input":[`+input+`]}`), http.Header{"Session_id": {"synthetic-failover"}}, Responses) + if err == nil { + io.Copy(io.Discard, response.Body) + response.Body.Close() + } + return err +} + +func boundAccount(t *testing.T, s *Service) string { + t.Helper() + var account string + if err := s.db.QueryRow("SELECT account_id FROM account_affinity").Scan(&account); err != nil { + t.Fatal(err) + } + return account +} + +const ( + failoverUser = `{"role":"user","content":"Synthetic question"}` + failoverOld = `{"type":"reasoning","summary":[],"encrypted_content":"gAAAA-synthetic-old"}` + failoverReply = `{"role":"assistant","content":[{"type":"output_text","text":"Synthetic answer"}]}` + failoverNew = `{"type":"reasoning","summary":[],"encrypted_content":"gAAAA-synthetic-new"}` + failoverFollowUp = `{"role":"user","content":"Synthetic follow-up"}` +) + +func TestQuotaFailoverMovesConversationAndWithholdsForeignState(t *testing.T) { + s, first, second, requests := failoverGateway(t, true) + saveExhaustedQuota(t, s, first) + if err := openTurn(t, s, strings.Join([]string{failoverUser, failoverOld, failoverReply, failoverFollowUp}, ",")); err != nil { + t.Fatal("exhausted conversation did not fail over", err) + } + if boundAccount(t, s) != second { + t.Fatal("binding did not move") + } + // A restart must keep withholding the previous account's state while new state passes through. + restarted := New(context.Background(), s.db, s.accounts, s.provider) + defer restarted.Close() + if err := openTurn(t, restarted, strings.Join([]string{failoverUser, failoverOld, failoverReply, failoverFollowUp, failoverNew, failoverReply, failoverFollowUp}, ",")); err != nil { + t.Fatal(err) + } + seen := requests() + secondSubject := seen[1].account + if secondSubject == seen[0].account { + t.Fatal("moved turn reused the exhausted account") + } + if len(seen) != 3 { + t.Fatal("unexpected upstream requests", len(seen)) + } + for i, request := range seen[1:] { + if request.account != secondSubject || strings.Contains(request.body, "synthetic-old") || !strings.Contains(request.body, "Synthetic follow-up") { + t.Fatal("moved turn leaked foreign state or lost transcript", i, request.account, request.body) + } + } + // The SDK may drop synthetic token text, so count reasoning items: only the current account's remains. + if strings.Count(seen[1].body, `"type":"reasoning"`) != 0 || strings.Count(seen[2].body, `"type":"reasoning"`) != 1 { + t.Fatal("state from the current account was withheld", seen[2].body) + } +} + +func TestQuotaFailoverDisabledKeepsStrictBinding(t *testing.T) { + s, first, _, _ := failoverGateway(t, false) + saveExhaustedQuota(t, s, first) + if err := openTurn(t, s, strings.Join([]string{failoverUser, failoverOld, failoverReply, failoverFollowUp}, ",")); !errors.Is(err, ErrQuotaExhausted) || boundAccount(t, s) != first { + t.Fatal("conversation moved without opt-in", err) + } +} + +func TestQuotaFailoverRefusesServerHeldState(t *testing.T) { + s, first, _, _ := failoverGateway(t, true) + saveExhaustedQuota(t, s, first) + if err := openTurn(t, s, `{"type":"item_reference","id":"synthetic-item"},`+failoverFollowUp); !errors.Is(err, ErrQuotaExhausted) || boundAccount(t, s) != first { + t.Fatal("stored provider state moved accounts", err) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index bcba03d3..561acb34 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -222,6 +222,8 @@ func (s *Service) Open(ctx context.Context, userID, groupID int64, raw []byte, h } // Hints are inert unless the group enables stickiness; selection reads that policy in its transaction. ctx = withStickyHints(ctx, requestStickyHints(headers, input, kind, session, fromMetadata)) + failover := newQuotaFailover(input, kind) + ctx = withQuotaFailover(ctx, failover) id, digest, err := s.leaseAccount(ctx, entry, session, provider, model, kind, affinity, reviewParentThread(input, headers, model, kind)) if id != "" { if account, lookupErr := s.accounts.Get(ctx, id); lookupErr == nil { @@ -244,6 +246,10 @@ func (s *Service) Open(ctx context.Context, userID, groupID int64, raw []byte, h } else { input["prompt_cache_key"], _ = json.Marshal(hex.EncodeToString(digest[:])) } + // Selection names encrypted items from accounts this conversation left; the leased account cannot read them. + if stripForeignState(input, failover.Strip) != nil { + return nil, upstream.ErrInput + } raw, err = json.Marshal(input) if err != nil { return nil, upstream.ErrInput diff --git a/internal/gateway/selection.go b/internal/gateway/selection.go index 86643522..10a3ed8b 100644 --- a/internal/gateway/selection.go +++ b/internal/gateway/selection.go @@ -161,6 +161,13 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i bindingModel := upstream.CatalogModelID(model) var replacement *db.ListSessionAffinitiesRow stickyAccount := "" + carrier := quotaFailoverFrom(ctx) + if carrier != nil { + carrier.Strip = nil + } + // failoverProvider is set when an exhausted binding moves; foreignState is the binding's new fingerprint set. + failoverProvider := "" + var foreignState []byte if session != "" && derived { // A derived binding is only a preference: unavailable resources fall back to normal scheduling. existing, err := sessionBinding(ctx, q, db.ListSessionAffinitiesParams{GroupID: groupID, UserID: userID, SessionHash: digest[:], Now: now}, scope) @@ -237,8 +244,19 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i return id, digest, err } if err := quotaAdmission(*bound, model); err != nil { - return id, digest, err + var quota *QuotaError + // Opt-in only: the default keeps the strict rule that conversations never move. + if !errors.As(err, "a) || !policy.Routing.QuotaFailover || carrier == nil || !carrier.Movable || existing.Provider != scope { + return id, digest, err + } + set, encoded, ok := mergeForeignState(existing.ForeignState, carrier.Present) + if !ok { + return id, digest, err + } + carrier.Strip, foreignState, failoverProvider, replacement = set, encoded, bound.Provider, existing } + } + if failoverProvider == "" && !reselect { if scheme != 0 { if _, err := allocations.Check(ctx, q, scheme, userID, groupID, id, model, now, s.location()); err != nil { return id, digest, err @@ -258,6 +276,10 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i if err := tx.Commit(); err != nil { return "", digest, err } + if carrier != nil && len(existing.ForeignState) > 0 { + // State written by accounts this conversation left stays off the current one. + carrier.Strip = decodeForeignState(existing.ForeignState) + } route.Candidates = 1 return id, digest, nil } @@ -293,7 +315,7 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i var coolingModel string var allocationErr error for _, account := range available { - if !s.accounts.ProviderEnabled(account.Provider) || !policy.AllowsProvider(account.Provider) || !allowed[account.ID] || !account.Enabled || account.Status == "reauth_required" || (provider != "" && account.Provider != provider) || (kind.codexOnly() && account.Provider != "codex") { + if !s.accounts.ProviderEnabled(account.Provider) || !policy.AllowsProvider(account.Provider) || !allowed[account.ID] || !account.Enabled || account.Status == "reauth_required" || (provider != "" && account.Provider != provider) || (kind.codexOnly() && account.Provider != "codex") || (failoverProvider != "" && account.Provider != failoverProvider) { if !s.accounts.ProviderEnabled(account.Provider) || !account.Enabled || account.Status == "reauth_required" { route.Exclusions.Unavailable = min(maxRouteCount, route.Exclusions.Unavailable+1) } else { @@ -453,7 +475,12 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i } s.next[cursor] = (s.next[cursor] + 1) % period if session != "" { - if replacement != nil && replacement.Provider == scope { + if failoverProvider != "" { + if bindingModel == "" { + bindingModel = replacement.Model + } + err = q.MoveAccountAffinity(ctx, db.MoveAccountAffinityParams{UserID: userID, GroupID: groupID, SessionHash: digest[:], Provider: scope, AccountID: id, Model: bindingModel, ExpiresAt: expires, ForeignState: foreignState}) + } else if replacement != nil && replacement.Provider == scope { err = q.ReplaceAccountAffinity(ctx, db.ReplaceAccountAffinityParams{UserID: userID, GroupID: groupID, SessionHash: digest[:], Provider: scope, AccountID: id, Model: bindingModel, ExpiresAt: expires}) } else { // Creating an auto binding from a legacy scope can also move the account. diff --git a/internal/groups/groups.go b/internal/groups/groups.go index 82b6ae14..02c77f3d 100644 --- a/internal/groups/groups.go +++ b/internal/groups/groups.go @@ -89,7 +89,7 @@ func (s *Service) List(ctx context.Context) ([]Group, error) { } result := make([]Group, 0, len(rows)) for _, r := range rows { - result = append(result, Group{SubscriptionCount: r.SubscriptionCount, ChannelCount: r.ChannelCount, Routing: Routing{Preference: r.RoutingPreference, AllowAPIFallback: r.AllowApiFallback != 0, SessionSticky: r.SessionSticky}, RestrictedModels: r.RestrictedModels, ID: r.ID, Name: r.Name, Enabled: r.Enabled, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, AccountCount: r.AccountCount, MemberCount: r.MemberCount}) + result = append(result, Group{SubscriptionCount: r.SubscriptionCount, ChannelCount: r.ChannelCount, Routing: Routing{Preference: r.RoutingPreference, AllowAPIFallback: r.AllowApiFallback != 0, SessionSticky: r.SessionSticky, QuotaFailover: r.QuotaFailover}, RestrictedModels: r.RestrictedModels, ID: r.ID, Name: r.Name, Enabled: r.Enabled, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, AccountCount: r.AccountCount, MemberCount: r.MemberCount}) } return result, nil } @@ -115,7 +115,7 @@ func (s *Service) Get(ctx context.Context, id int64) (Detail, error) { if err != nil { return Detail{}, err } - result := Detail{Group: Group{Routing: Routing{Preference: row.RoutingPreference, AllowAPIFallback: row.AllowApiFallback != 0, SessionSticky: row.SessionSticky}, RestrictedModels: row.RestrictedModels, ID: row.ID, Name: row.Name, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, AccountCount: int64(len(ids)), MemberCount: count}, AccountIDs: ids, Resources: []Resource{}} + result := Detail{Group: Group{Routing: Routing{Preference: row.RoutingPreference, AllowAPIFallback: row.AllowApiFallback != 0, SessionSticky: row.SessionSticky, QuotaFailover: row.QuotaFailover}, RestrictedModels: row.RestrictedModels, ID: row.ID, Name: row.Name, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, AccountCount: int64(len(ids)), MemberCount: count}, AccountIDs: ids, Resources: []Resource{}} for _, sourceID := range ids { source, err := q.GetAccount(ctx, db.GetAccountParams{ID: sourceID, TenantID: s.tenantID}) if err != nil { @@ -264,7 +264,7 @@ func (s *Service) Save(ctx context.Context, id int64, input Input) (Detail, erro if input.Routing.AllowAPIFallback { fallback = 1 } - if err := q.SetGroupRouting(ctx, db.SetGroupRoutingParams{ID: id, Preference: input.Routing.Preference, AllowApiFallback: fallback, SessionSticky: input.Routing.SessionSticky}); err != nil { + if err := q.SetGroupRouting(ctx, db.SetGroupRoutingParams{ID: id, Preference: input.Routing.Preference, AllowApiFallback: fallback, SessionSticky: input.Routing.SessionSticky, QuotaFailover: input.Routing.QuotaFailover}); err != nil { return Detail{}, err } } diff --git a/internal/groups/policy.go b/internal/groups/policy.go index e615d888..65ed2ca8 100644 --- a/internal/groups/policy.go +++ b/internal/groups/policy.go @@ -95,5 +95,5 @@ func ReadPolicy(ctx context.Context, q *db.Queries, userID, groupID int64) (Mode return ModelPolicy{}, err } hasSubscriptions, err := q.GroupHasSubscriptions(ctx, groupID) - return ModelPolicy{Restricted: group.RestrictedModels, Models: models, Routing: Routing{Preference: group.RoutingPreference, AllowAPIFallback: group.AllowApiFallback != 0, SessionSticky: group.SessionSticky}, HasSubscriptions: hasSubscriptions}, err + return ModelPolicy{Restricted: group.RestrictedModels, Models: models, Routing: Routing{Preference: group.RoutingPreference, AllowAPIFallback: group.AllowApiFallback != 0, SessionSticky: group.SessionSticky, QuotaFailover: group.QuotaFailover}, HasSubscriptions: hasSubscriptions}, err } diff --git a/internal/groups/resources.go b/internal/groups/resources.go index bf2bb206..8fb7e159 100644 --- a/internal/groups/resources.go +++ b/internal/groups/resources.go @@ -12,6 +12,9 @@ type Routing struct { AllowAPIFallback bool `json:"allow_api_fallback"` // SessionSticky derives conversation keys for sessionless clients and lets subagents inherit their parent's resource. SessionSticky bool `json:"session_sticky"` + // QuotaFailover lets an existing conversation leave a quota-exhausted account; encrypted state + // from the previous account is withheld from the new one. + QuotaFailover bool `json:"quota_failover"` } func (r Routing) valid() bool { diff --git a/internal/storage/db/gateway.sql.go b/internal/storage/db/gateway.sql.go index 52fc1e6c..2ec331bb 100644 --- a/internal/storage/db/gateway.sql.go +++ b/internal/storage/db/gateway.sql.go @@ -167,7 +167,7 @@ func (q *Queries) ListRoutingAccounts(ctx context.Context, arg ListRoutingAccoun } const listSessionAffinities = `-- name: ListSessionAffinities :many -SELECT provider, account_id, expires_at, model, rebound FROM account_affinity +SELECT provider, account_id, expires_at, model, rebound, foreign_state FROM account_affinity WHERE user_id = ?1 AND group_id = ?2 AND session_hash = ?3 AND expires_at > ?4 ORDER BY provider @@ -181,11 +181,12 @@ type ListSessionAffinitiesParams struct { } type ListSessionAffinitiesRow struct { - Provider string - AccountID string - ExpiresAt int64 - Model string - Rebound bool + Provider string + AccountID string + ExpiresAt int64 + Model string + Rebound bool + ForeignState []byte } func (q *Queries) ListSessionAffinities(ctx context.Context, arg ListSessionAffinitiesParams) ([]ListSessionAffinitiesRow, error) { @@ -208,6 +209,7 @@ func (q *Queries) ListSessionAffinities(ctx context.Context, arg ListSessionAffi &i.ExpiresAt, &i.Model, &i.Rebound, + &i.ForeignState, ); err != nil { return nil, err } @@ -222,6 +224,37 @@ func (q *Queries) ListSessionAffinities(ctx context.Context, arg ListSessionAffi return items, nil } +const moveAccountAffinity = `-- name: MoveAccountAffinity :exec +UPDATE account_affinity SET account_id = ?1, model = ?2, expires_at = ?3, rebound = 1, foreign_state = ?4 +WHERE user_id = ?5 AND group_id = ?6 AND session_hash = ?7 AND provider = ?8 +` + +type MoveAccountAffinityParams struct { + AccountID string + Model string + ExpiresAt int64 + ForeignState []byte + UserID int64 + GroupID int64 + SessionHash []byte + Provider string +} + +// Quota failover records the new account and the encrypted state it must never receive. +func (q *Queries) MoveAccountAffinity(ctx context.Context, arg MoveAccountAffinityParams) error { + _, err := q.db.ExecContext(ctx, moveAccountAffinity, + arg.AccountID, + arg.Model, + arg.ExpiresAt, + arg.ForeignState, + arg.UserID, + arg.GroupID, + arg.SessionHash, + arg.Provider, + ) + return err +} + const pruneAccountAffinity = `-- name: PruneAccountAffinity :exec DELETE FROM account_affinity WHERE expires_at <= ?1 ` diff --git a/internal/storage/db/groups.sql.go b/internal/storage/db/groups.sql.go index 6c36bdad..493a0ab4 100644 --- a/internal/storage/db/groups.sql.go +++ b/internal/storage/db/groups.sql.go @@ -171,7 +171,7 @@ func (q *Queries) FindGroupName(ctx context.Context, arg FindGroupNameParams) (i } const getGroup = `-- name: GetGroup :one -SELECT id, name, enabled, created_at, updated_at, restricted_models, tenant_id, routing_preference, allow_api_fallback, session_sticky, deleted_at FROM account_groups WHERE id=?1 +SELECT id, name, enabled, created_at, updated_at, restricted_models, tenant_id, routing_preference, allow_api_fallback, session_sticky, deleted_at, quota_failover FROM account_groups WHERE id=?1 ` func (q *Queries) GetGroup(ctx context.Context, id int64) (AccountGroup, error) { @@ -189,12 +189,13 @@ func (q *Queries) GetGroup(ctx context.Context, id int64) (AccountGroup, error) &i.AllowApiFallback, &i.SessionSticky, &i.DeletedAt, + &i.QuotaFailover, ) return i, err } const getTenantGroup = `-- name: GetTenantGroup :one -SELECT id, name, enabled, created_at, updated_at, restricted_models, tenant_id, routing_preference, allow_api_fallback, session_sticky, deleted_at FROM account_groups WHERE id=?1 AND tenant_id=?2 AND deleted_at IS NULL +SELECT id, name, enabled, created_at, updated_at, restricted_models, tenant_id, routing_preference, allow_api_fallback, session_sticky, deleted_at, quota_failover FROM account_groups WHERE id=?1 AND tenant_id=?2 AND deleted_at IS NULL ` type GetTenantGroupParams struct { @@ -217,6 +218,7 @@ func (q *Queries) GetTenantGroup(ctx context.Context, arg GetTenantGroupParams) &i.AllowApiFallback, &i.SessionSticky, &i.DeletedAt, + &i.QuotaFailover, ) return i, err } @@ -403,7 +405,7 @@ func (q *Queries) ListGroupModels(ctx context.Context, groupID int64) ([]string, } const listGroups = `-- name: ListGroups :many -SELECT g.id, g.name, g.enabled, g.created_at, g.updated_at, g.restricted_models, g.tenant_id, g.routing_preference, g.allow_api_fallback, g.session_sticky, g.deleted_at, (SELECT count(*) FROM group_accounts a WHERE a.group_id=g.id) AS account_count, +SELECT g.id, g.name, g.enabled, g.created_at, g.updated_at, g.restricted_models, g.tenant_id, g.routing_preference, g.allow_api_fallback, g.session_sticky, g.deleted_at, g.quota_failover, (SELECT count(*) FROM group_accounts a WHERE a.group_id=g.id) AS account_count, (SELECT count(*) FROM group_accounts ga JOIN accounts a ON a.id=ga.account_id WHERE ga.group_id=g.id AND a.provider!='openai') AS subscription_count, (SELECT count(*) FROM group_accounts ga JOIN accounts a ON a.id=ga.account_id WHERE ga.group_id=g.id AND a.provider='openai') AS channel_count, (SELECT count(*) FROM effective_group_access access WHERE access.group_id=g.id) AS member_count @@ -422,6 +424,7 @@ type ListGroupsRow struct { AllowApiFallback int64 SessionSticky bool DeletedAt *int64 + QuotaFailover bool AccountCount int64 SubscriptionCount int64 ChannelCount int64 @@ -449,6 +452,7 @@ func (q *Queries) ListGroups(ctx context.Context, tenantID int64) ([]ListGroupsR &i.AllowApiFallback, &i.SessionSticky, &i.DeletedAt, + &i.QuotaFailover, &i.AccountCount, &i.SubscriptionCount, &i.ChannelCount, @@ -590,14 +594,15 @@ func (q *Queries) SetGroupModelPolicy(ctx context.Context, arg SetGroupModelPoli } const setGroupRouting = `-- name: SetGroupRouting :exec -UPDATE account_groups SET routing_preference=?1,allow_api_fallback=?2,session_sticky=?3 -WHERE id=?4 +UPDATE account_groups SET routing_preference=?1,allow_api_fallback=?2,session_sticky=?3,quota_failover=?4 +WHERE id=?5 ` type SetGroupRoutingParams struct { Preference string AllowApiFallback int64 SessionSticky bool + QuotaFailover bool ID int64 } @@ -606,6 +611,7 @@ func (q *Queries) SetGroupRouting(ctx context.Context, arg SetGroupRoutingParams arg.Preference, arg.AllowApiFallback, arg.SessionSticky, + arg.QuotaFailover, arg.ID, ) return err diff --git a/internal/storage/db/models.go b/internal/storage/db/models.go index 08723509..c377c5af 100644 --- a/internal/storage/db/models.go +++ b/internal/storage/db/models.go @@ -42,6 +42,7 @@ type AccountGroup struct { AllowApiFallback int64 SessionSticky bool DeletedAt *int64 + QuotaFailover bool } type AlertState struct { diff --git a/internal/storage/migrations/035_quota_failover.sql b/internal/storage/migrations/035_quota_failover.sql new file mode 100644 index 00000000..3f1f8179 --- /dev/null +++ b/internal/storage/migrations/035_quota_failover.sql @@ -0,0 +1,6 @@ +-- Optional per-group quota failover lets a conversation leave a quota-exhausted account. +ALTER TABLE account_groups ADD COLUMN quota_failover INTEGER NOT NULL DEFAULT 0 CHECK(quota_failover IN (0,1)); + +-- Fingerprints of encrypted provider state written by previous accounts; later turns strip those +-- items because the current account cannot decrypt them. Bounded to 4,096 16-byte digests. +ALTER TABLE account_affinity ADD COLUMN foreign_state BLOB CHECK(foreign_state IS NULL OR length(foreign_state) <= 65536); diff --git a/internal/storage/queries/gateway.sql b/internal/storage/queries/gateway.sql index 4d3ed5a4..7e6c0495 100644 --- a/internal/storage/queries/gateway.sql +++ b/internal/storage/queries/gateway.sql @@ -25,8 +25,13 @@ AND (expires_at < sqlc.arg(threshold) OR model != sqlc.arg(model)); UPDATE account_affinity SET account_id = sqlc.arg(account_id), model = sqlc.arg(model), expires_at = sqlc.arg(expires_at), rebound = rebound OR account_id != sqlc.arg(account_id) WHERE user_id = sqlc.arg(user_id) AND group_id = sqlc.arg(group_id) AND session_hash = sqlc.arg(session_hash) AND provider = sqlc.arg(provider); +-- name: MoveAccountAffinity :exec +-- Quota failover records the new account and the encrypted state it must never receive. +UPDATE account_affinity SET account_id = sqlc.arg(account_id), model = sqlc.arg(model), expires_at = sqlc.arg(expires_at), rebound = 1, foreign_state = sqlc.arg(foreign_state) +WHERE user_id = sqlc.arg(user_id) AND group_id = sqlc.arg(group_id) AND session_hash = sqlc.arg(session_hash) AND provider = sqlc.arg(provider); + -- name: ListSessionAffinities :many -SELECT provider, account_id, expires_at, model, rebound FROM account_affinity +SELECT provider, account_id, expires_at, model, rebound, foreign_state FROM account_affinity WHERE user_id = sqlc.arg(user_id) AND group_id = sqlc.arg(group_id) AND session_hash = sqlc.arg(session_hash) AND expires_at > sqlc.arg(now) ORDER BY provider; diff --git a/internal/storage/queries/groups.sql b/internal/storage/queries/groups.sql index f5923f8a..448b09d6 100644 --- a/internal/storage/queries/groups.sql +++ b/internal/storage/queries/groups.sql @@ -98,7 +98,7 @@ INSERT INTO group_models(group_id,model) VALUES(sqlc.arg(group_id),sqlc.arg(mode SELECT model FROM group_models WHERE group_id=sqlc.arg(group_id) ORDER BY model; -- name: SetGroupRouting :exec -UPDATE account_groups SET routing_preference=sqlc.arg(preference),allow_api_fallback=sqlc.arg(allow_api_fallback),session_sticky=sqlc.arg(session_sticky) +UPDATE account_groups SET routing_preference=sqlc.arg(preference),allow_api_fallback=sqlc.arg(allow_api_fallback),session_sticky=sqlc.arg(session_sticky),quota_failover=sqlc.arg(quota_failover) WHERE id=sqlc.arg(id); -- name: GroupHasSubscriptions :one diff --git a/sqlc.yaml b/sqlc.yaml index 8594a3cd..612aaa96 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -28,3 +28,5 @@ sql: go_type: "bool" - column: "account_groups.session_sticky" go_type: "bool" + - column: "account_groups.quota_failover" + go_type: "bool" diff --git a/web/src/components/GroupEditor.tsx b/web/src/components/GroupEditor.tsx index b7f47666..0f8ed7f9 100644 --- a/web/src/components/GroupEditor.tsx +++ b/web/src/components/GroupEditor.tsx @@ -116,6 +116,9 @@ function GroupForm({ value?.routing.allow_api_fallback ?? false, ) const [sticky, setSticky] = useState(value?.routing.session_sticky ?? false) + const [failover, setFailover] = useState( + value?.routing.quota_failover ?? false, + ) const [restricted, setRestricted] = useState( value?.restricted_models ?? false, ) @@ -142,6 +145,7 @@ function GroupForm({ preference, allow_api_fallback: fallback, session_sticky: sticky, + quota_failover: failover, }, model_policy: { restricted, @@ -404,6 +408,22 @@ function GroupForm({ > {t('sessionStickyHint')}
+ ++ {t('quotaFailoverHint')} +