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')} +

diff --git a/web/src/lib/groups.ts b/web/src/lib/groups.ts index 26f26d76..25041efa 100644 --- a/web/src/lib/groups.ts +++ b/web/src/lib/groups.ts @@ -11,11 +11,13 @@ const groupSchema = z.object({ preference: z.enum(['protocol', 'subscription_first', 'api_first']), allow_api_fallback: z.boolean(), session_sticky: z.boolean().optional().default(false), + quota_failover: z.boolean().optional().default(false), }) .default({ preference: 'protocol', allow_api_fallback: true, session_sticky: false, + quota_failover: false, }), id: z.number().int().positive(), name: z.string().min(1), @@ -78,6 +80,7 @@ export function saveGroup({ preference: 'protocol' | 'subscription_first' | 'api_first' allow_api_fallback: boolean session_sticky: boolean + quota_failover: boolean } model_policy: { restricted: boolean; models: string[] } }) { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 26935739..af729a1e 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1563,6 +1563,9 @@ "sessionSticky": "تثبيت الجلسة", "sessionStickyHint": "يُبقي المحادثات التي لا تحمل معرّف جلسة على مورد واحد باستخدام تجزئة الطلب الافتتاحي، ويجعل الوكلاء الفرعيين يفضلون مورد الوكيل الأصلي. تستمر الموارد غير المتاحة في التحويل التلقائي. يحسّن إصابات ذاكرة التخزين المؤقت للطلبات.", "sessionStickyEnabled": "تثبيت الجلسة مفعّل", + "quotaFailover": "تبديل الحساب عند نفاد الحصة", + "quotaFailoverHint": "يسمح بنقل محادثة قائمة إلى اشتراك آخر لدى المزوّد نفسه عند نفاد حصة حسابها بدلاً من الفشل. لا يُرسل الاستدلال المشفّر من الحساب السابق، ويُحتفظ بسجل المحادثة المرئي. لا تنتقل المحادثات التي تستخدم حالة مخزّنة على الخادم.", + "quotaFailoverEnabled": "التبديل عند نفاد الحصة مفعّل", "editChannel": "تعديل القناة", "channelEditDescription": "أعد تسمية القناة أو استبدل مفتاحها. لاستخدام نقطة نهاية أو بروتوكول آخر، أضف قناة جديدة حتى لا يصل المفتاح المحفوظ إلى مزوّد آخر.", "channelKeyKeepHint": "اتركه فارغًا للاحتفاظ بالمفتاح المحفوظ. لا يتم عرض المفتاح المحفوظ أبدًا.", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index a0ff4440..4577531c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1543,6 +1543,9 @@ "sessionSticky": "Sitzungsbindung", "sessionStickyHint": "Hält Unterhaltungen ohne Sitzungs-ID über einen Hash des ersten Prompts auf einer Ressource und lässt Subagenten die Ressource ihres Elternteils bevorzugen. Nicht verfügbare Ressourcen werden weiterhin umgangen. Verbessert Prompt-Cache-Treffer beim Anbieter.", "sessionStickyEnabled": "Sitzungsbindung aktiv", + "quotaFailover": "Konto wechseln, wenn das Kontingent erschöpft ist", + "quotaFailoverHint": "Lässt eine bestehende Unterhaltung zu einem anderen Abonnement desselben Anbieters wechseln, wenn das Kontingent ihres Kontos erschöpft ist, statt fehlzuschlagen. Verschlüsseltes Reasoning des vorherigen Kontos wird nicht weitergegeben; das sichtbare Transkript bleibt erhalten. Unterhaltungen mit serverseitig gespeichertem Zustand wechseln nie.", + "quotaFailoverEnabled": "Kontingent-Wechsel aktiv", "editChannel": "Kanal bearbeiten", "channelEditDescription": "Benennen Sie den Kanal um oder ersetzen Sie seinen Schlüssel. Für einen anderen Endpunkt oder ein anderes Protokoll fügen Sie einen neuen Kanal hinzu, damit der gespeicherte Schlüssel nie einen anderen Anbieter erreicht.", "channelKeyKeepHint": "Leer lassen, um den gespeicherten Schlüssel zu behalten. Der gespeicherte Schlüssel wird nie angezeigt.", diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 2ad7c53f..0f44b2fa 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1932,6 +1932,10 @@ export const en = { sessionStickyHint: 'Keeps conversations without a session ID on one resource, using a hash of the opening prompt, and lets subagents prefer their parent’s resource. Unavailable resources still fail over. Improves upstream prompt cache hits.', sessionStickyEnabled: 'Session stickiness on', + quotaFailover: 'Switch accounts when quota runs out', + quotaFailoverHint: + 'Lets an existing conversation move to another subscription of the same provider when its account’s quota is exhausted, instead of failing. Encrypted reasoning from the previous account is withheld; the visible transcript is kept. Conversations using server-stored state never move.', + quotaFailoverEnabled: 'Quota failover on', editChannel: 'Edit channel', channelKeyKeepHint: 'Leave blank to keep the stored key. The stored key is never shown.', diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 6a739f0a..a643d6d7 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1548,6 +1548,9 @@ "sessionSticky": "Persistencia de sesión", "sessionStickyHint": "Mantiene las conversaciones sin ID de sesión en un mismo recurso mediante un hash del mensaje inicial y hace que los subagentes prefieran el recurso de su padre. Los recursos no disponibles siguen conmutando. Mejora los aciertos de caché de prompts.", "sessionStickyEnabled": "Persistencia de sesión activada", + "quotaFailover": "Cambiar de cuenta al agotar la cuota", + "quotaFailoverHint": "Permite que una conversación existente pase a otra suscripción del mismo proveedor cuando se agota la cuota de su cuenta, en lugar de fallar. El razonamiento cifrado de la cuenta anterior no se envía; se conserva la transcripción visible. Las conversaciones con estado almacenado en el servidor nunca se mueven.", + "quotaFailoverEnabled": "Cambio por cuota activado", "editChannel": "Editar canal", "channelEditDescription": "Cambia el nombre del canal o sustituye su clave. Para usar otro endpoint o protocolo, añade un canal nuevo para que la clave guardada nunca llegue a otro proveedor.", "channelKeyKeepHint": "Déjalo en blanco para conservar la clave guardada. La clave guardada nunca se muestra.", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 8d8086f8..43517512 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1548,6 +1548,9 @@ "sessionSticky": "Affinité de session", "sessionStickyHint": "Conserve les conversations sans identifiant de session sur une même ressource grâce à un hachage du prompt initial, et fait préférer aux sous-agents la ressource de leur parent. Les ressources indisponibles basculent toujours. Améliore les succès du cache de prompts.", "sessionStickyEnabled": "Affinité de session activée", + "quotaFailover": "Changer de compte quand le quota est épuisé", + "quotaFailoverHint": "Permet à une conversation existante de passer à un autre abonnement du même fournisseur lorsque le quota de son compte est épuisé, au lieu d’échouer. Le raisonnement chiffré de l’ancien compte n’est pas transmis ; la transcription visible est conservée. Les conversations utilisant un état stocké côté serveur ne changent jamais de compte.", + "quotaFailoverEnabled": "Bascule sur quota activée", "editChannel": "Modifier le canal", "channelEditDescription": "Renommez le canal ou remplacez sa clé. Pour utiliser un autre point de terminaison ou protocole, ajoutez un nouveau canal afin que la clé enregistrée n’atteigne jamais un autre fournisseur.", "channelKeyKeepHint": "Laissez vide pour conserver la clé enregistrée. La clé enregistrée n’est jamais affichée.", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 701b0acb..16460dc7 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -1543,6 +1543,9 @@ "sessionSticky": "セッションの固定", "sessionStickyHint": "セッション ID のない会話を冒頭プロンプトのハッシュで同じリソースに固定し、サブエージェントには親のリソースを優先させます。利用できないリソースは引き続きフェイルオーバーします。上流のプロンプトキャッシュのヒット率が向上します。", "sessionStickyEnabled": "セッション固定オン", + "quotaFailover": "クォータ切れでアカウントを切り替える", + "quotaFailoverHint": "既存の会話のアカウントでクォータが尽きた場合、エラーにせず同じプロバイダーの別のサブスクリプションへ移します。元アカウントの暗号化された推論は送信せず、表示されている会話履歴は保持します。サーバー保存の状態を使う会話は移動しません。", + "quotaFailoverEnabled": "クォータ切れ時の切り替えオン", "editChannel": "チャンネルを編集", "channelEditDescription": "チャンネル名の変更やキーの差し替えができます。別のエンドポイントやプロトコルを使う場合は、保存済みのキーが別の上流に送られないよう新しいチャンネルを追加してください。", "channelKeyKeepHint": "空欄のままにすると保存済みのキーを維持します。保存済みのキーは表示されません。", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 991ac9ae..23962bf1 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1543,6 +1543,9 @@ "sessionSticky": "세션 고정", "sessionStickyHint": "세션 ID가 없는 대화를 시작 프롬프트의 해시로 같은 리소스에 고정하고, 하위 에이전트가 상위 리소스를 우선 사용하게 합니다. 사용할 수 없는 리소스는 계속 장애 조치됩니다. 업스트림 프롬프트 캐시 적중률이 높아집니다.", "sessionStickyEnabled": "세션 고정 켜짐", + "quotaFailover": "할당량 소진 시 계정 전환", + "quotaFailoverHint": "기존 대화의 계정 할당량이 소진되면 실패하는 대신 같은 공급자의 다른 구독으로 옮깁니다. 이전 계정의 암호화된 추론은 보내지 않으며, 보이는 대화 기록은 유지됩니다. 서버에 저장된 상태를 쓰는 대화는 옮기지 않습니다.", + "quotaFailoverEnabled": "할당량 소진 전환 켜짐", "editChannel": "채널 편집", "channelEditDescription": "채널 이름을 바꾸거나 키를 교체합니다. 다른 엔드포인트나 프로토콜을 사용하려면 저장된 키가 다른 업스트림으로 전송되지 않도록 새 채널을 추가하세요.", "channelKeyKeepHint": "비워 두면 저장된 키가 유지됩니다. 저장된 키는 표시되지 않습니다.", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 15816d14..4ec40e5b 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1548,6 +1548,9 @@ "sessionSticky": "Persistência de sessão", "sessionStickyHint": "Mantém conversas sem ID de sessão em um mesmo recurso usando um hash do prompt inicial e faz os subagentes preferirem o recurso do pai. Recursos indisponíveis continuam com failover. Melhora os acertos de cache de prompt.", "sessionStickyEnabled": "Persistência de sessão ativada", + "quotaFailover": "Trocar de conta quando a cota acabar", + "quotaFailoverHint": "Permite que uma conversa existente passe para outra assinatura do mesmo provedor quando a cota da conta se esgota, em vez de falhar. O raciocínio criptografado da conta anterior não é enviado; a transcrição visível é mantida. Conversas com estado armazenado no servidor nunca mudam de conta.", + "quotaFailoverEnabled": "Troca por cota ativada", "editChannel": "Editar canal", "channelEditDescription": "Renomeie o canal ou substitua sua chave. Para usar outro endpoint ou protocolo, adicione um novo canal para que a chave salva nunca chegue a outro provedor.", "channelKeyKeepHint": "Deixe em branco para manter a chave salva. A chave salva nunca é exibida.", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 7db42a2e..304f182e 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1553,6 +1553,9 @@ "sessionSticky": "Привязка сессий", "sessionStickyHint": "Закрепляет диалоги без идентификатора сессии за одним ресурсом по хешу первого запроса и позволяет субагентам предпочитать ресурс родителя. Недоступные ресурсы по-прежнему переключаются. Повышает попадания в кэш промптов.", "sessionStickyEnabled": "Привязка сессий включена", + "quotaFailover": "Переключать аккаунт при исчерпании квоты", + "quotaFailoverHint": "Позволяет существующему диалогу перейти на другую подписку того же провайдера, когда квота его аккаунта исчерпана, вместо ошибки. Зашифрованные рассуждения прежнего аккаунта не передаются; видимая история сохраняется. Диалоги с состоянием, хранящимся на сервере, не переключаются.", + "quotaFailoverEnabled": "Переключение по квоте включено", "editChannel": "Изменить канал", "channelEditDescription": "Переименуйте канал или замените ключ. Для другого адреса или протокола добавьте новый канал, чтобы сохранённый ключ не попал к другому провайдеру.", "channelKeyKeepHint": "Оставьте пустым, чтобы сохранить текущий ключ. Сохранённый ключ никогда не отображается.", diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index 904fc079..bcf075ef 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -1543,6 +1543,9 @@ "sessionSticky": "工作階段黏性", "sessionStickyHint": "對未攜帶工作階段 ID 的對話,依開頭提示詞的雜湊固定到同一資源,並讓子代理優先使用父工作階段的資源。資源不可用時仍會自動切換。可提高上游提示詞快取命中率。", "sessionStickyEnabled": "已開啟工作階段黏性", + "quotaFailover": "額度耗盡時允許工作階段切換帳號", + "quotaFailoverHint": "既有工作階段所在帳號額度耗盡時,改為切換到同一服務商的其他訂閱繼續,而不是直接報錯。原帳號的加密推理內容不會傳送給新帳號,可見的對話紀錄會保留。使用伺服器端儲存狀態的工作階段不會切換。", + "quotaFailoverEnabled": "已開啟額度耗盡切換", "editChannel": "編輯渠道", "channelEditDescription": "修改渠道名稱或更換金鑰。如需使用其他端點或協定,請新建渠道,避免已儲存的金鑰被傳送到其他上游。", "channelKeyKeepHint": "留空則保留已儲存的金鑰。已儲存的金鑰不會顯示。", diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index f1d380a1..455ebde5 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1765,6 +1765,10 @@ export const zh: Record = { sessionStickyHint: '对未携带会话 ID 的对话,按开头提示词的哈希固定到同一资源,并让子代理优先使用父会话的资源。资源不可用时仍会自动切换。可提高上游提示词缓存命中率。', sessionStickyEnabled: '已开启会话粘性', + quotaFailover: '额度耗尽时允许会话切换账号', + quotaFailoverHint: + '已有会话所在账号额度耗尽时,改为切换到同一服务商的其他订阅继续,而不是直接报错。原账号的加密推理内容不会发送给新账号,可见的对话记录会保留。使用服务端存储状态的会话不会切换。', + quotaFailoverEnabled: '已开启额度耗尽切换', editChannel: '编辑渠道', channelKeyKeepHint: '留空则保留已保存的密钥。已保存的密钥不会显示。', saveChannel: '保存渠道', diff --git a/web/src/pages/Groups.test.tsx b/web/src/pages/Groups.test.tsx index 4f12a4b3..b4fef7c0 100644 --- a/web/src/pages/Groups.test.tsx +++ b/web/src/pages/Groups.test.tsx @@ -63,6 +63,7 @@ it('selects subscriptions and channels separately with API fallback off by defau preference: 'subscription_first', allow_api_fallback: false, session_sticky: false, + quota_failover: false, }) return Promise.resolve( response({ @@ -129,6 +130,7 @@ it('creates a pool with only selected accounts', async () => { preference: 'subscription_first', allow_api_fallback: false, session_sticky: false, + quota_failover: false, }, model_policy: { restricted: false, models: [] }, }) diff --git a/web/src/pages/Groups.tsx b/web/src/pages/Groups.tsx index d997c39e..3f3cea0e 100644 --- a/web/src/pages/Groups.tsx +++ b/web/src/pages/Groups.tsx @@ -131,6 +131,8 @@ export function Groups() { )} {group.routing.session_sticky && ` · ${t('sessionStickyEnabled')}`} + {group.routing.quota_failover && + ` · ${t('quotaFailoverEnabled')}`}