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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/ACCOUNT_POOL_LEASES_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,12 @@ account-pools:
- 携带 previous_response_id 的会话无法安全跨账号,返回 409 `pool_lease_session_expired`,需使用完整历史新建会话。当前采用保守保护:发生过换号的租约在剩余有效期内不接受 previous_response_id,避免误把旧账号响应 ID 交给新账号;完整历史请求不受影响。
- 短暂的通用 429 仍按现有退避处理;只有明确 usage_limit_reached、insufficient_quota、usage limit 等账号额度错误才会换号。裸 403 或内容策略错误不会触发换号。
- 此版本不改变前端 API 契约,可继续使用 v1.22.2-cpa.21。线上升级须由管理员明确执行,不随发布自动部署。

## New API 用户 ID 为 1 的临时调用

- 仅对绑定了 New API 实例的租约 Key 生效。先校验可信身份的实例与 Key 配置一致,再对字符串精确等于 `1` 的用户 ID 使用临时占用;普通 Key、其他用户或错误实例不能借此绕过分组权限。
- ID 为 1 的所有调用均适用,包括管理员正常聊天;不是只识别渠道测试。其他管理员 ID 的测试仍采用一小时租约。
- 每个请求原子选择授权组内一个空闲账号,临时占用参与同一份账号排他判断,不能抢占普通用户或其他管理员请求使用的账号。同一管理员并发请求也不共用账号;无空闲账号返回 pool_busy。
- 临时占用不写入持久化租约文件,不设一小时有效期。请求结束即释放,取消的流式请求仍等上游生产者排空再释放,避免提前复用。面板在请求期间显示“临时占用”。
- 升级前 ID 为 1 的旧一小时租约在其下一次有可用账号的新请求时转换。先等待旧在途归零,再原子持久化移除旧长期租约;写入失败保留原状态,不能清空后继续分配。其他用户的租约不变。
- 因为请求间不保证账号相同,ID 为 1 的调用不支持 previous_response_id,返回 409 pool_temporary_session_unsupported,需携带完整对话历史。临时调用的额度耗尽换号沿用同组空闲账号规则,换号后也只占用到请求结束。
2 changes: 1 addition & 1 deletion internal/api/handlers/management/account_pools.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (h *Handler) GetAccountPools(c *gin.Context) {
leaseError = "lease state unavailable"
}
for _, l := range rows {
leases = append(leases, gin.H{"id": l.ID, "group-id": l.Group, "credential-id": l.Credential, "legacy-group": l.LegacyGroup, "owner": l.Owner, "expires-at": l.Expires, "active": l.Active})
leases = append(leases, gin.H{"id": l.ID, "group-id": l.Group, "credential-id": l.Credential, "legacy-group": l.LegacyGroup, "temporary": l.Temporary, "owner": l.Owner, "expires-at": l.Expires, "active": l.Active})
}
}
c.JSON(http.StatusOK, gin.H{"lease-unit": "account", "leases": leases, "lease-error": leaseError, "config": pools, "keys": keys, "credentials": credentials, "revision": poolConfigRevision(cfg), "home-enabled": cfg.Home.Enabled})
Expand Down
85 changes: 78 additions & 7 deletions internal/poollease/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Candidate struct {
}

type Lease struct {
Temporary bool `json:"-"`
Reassigned bool `json:"reassigned,omitempty"`
Credential string `json:"credential,omitempty"`
LegacyGroup bool `json:"legacy_group,omitempty"`
Expand Down Expand Up @@ -138,7 +139,7 @@ func (s *Store) Lookup(owner, policy string, now time.Time) (Lease, bool, error)
return Lease{}, false, err
}
for _, l := range s.leases {
if l.Owner == owner {
if l.Owner == owner && !l.Temporary {
return *l, true, nil
}
}
Expand All @@ -162,7 +163,7 @@ func (s *Store) Acquire(owner, policy string, allowed map[string]bool, candidate
return Lease{}, nil, err
}
for _, l := range s.leases {
if l.Owner != owner {
if l.Owner != owner || l.Temporary {
continue
}
if !allowed[l.Group] {
Expand Down Expand Up @@ -222,13 +223,19 @@ func (s *Store) releaser(id string) func() {
defer s.mu.Unlock()
if l := s.leases[id]; l != nil {
l.Active--
if l.Temporary && l.Active == 0 {
delete(s.leases, id)
}
}
})
}
}
func (s *Store) save() error {
rows := make([]Lease, 0, len(s.leases))
for _, l := range s.leases {
if l.Temporary {
continue
}
rows = append(rows, *l)
}
sort.Slice(rows, func(i, j int) bool {
Expand Down Expand Up @@ -287,7 +294,7 @@ func (s *Store) Replace(expected Lease, candidates []Candidate, now time.Time) (
return Lease{}, nil, err
}
old := s.leases[expected.ID]
if old == nil || old.Owner != expected.Owner || old.Credential != expected.Credential || old.LegacyGroup || old.Active != 1 || !now.Before(old.Expires) {
if old == nil || old.Owner != expected.Owner || old.Credential != expected.Credential || old.LegacyGroup || old.Active != 1 || (!old.Temporary && !now.Before(old.Expires)) {
return Lease{}, nil, ErrBusy
}
for _, c := range candidates {
Expand All @@ -302,10 +309,12 @@ func (s *Store) Replace(expected Lease, candidates []Candidate, now time.Time) (
next.ID, next.Credential, next.Reassigned = hex.EncodeToString(id), c.Credential, true
delete(s.leases, old.ID)
s.leases[next.ID] = &next
if err := s.save(); err != nil {
delete(s.leases, next.ID)
s.leases[old.ID] = old
return Lease{}, nil, err
if !old.Temporary {
if err := s.save(); err != nil {
delete(s.leases, next.ID)
s.leases[old.ID] = old
return Lease{}, nil, err
}
}
return next, s.releaser(next.ID), nil
}
Expand All @@ -323,3 +332,65 @@ func (s *Store) Hold(id string) (func(), bool) {
l.Active++
return s.releaser(id), true
}

// LookupID binds temporary requests independently, even for the same owner.
func (s *Store) LookupID(id, policy string, now time.Time) (Lease, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.check(policy, now); err != nil {
return Lease{}, false, err
}
l := s.leases[id]
if l == nil {
return Lease{}, false, nil
}
return *l, true, nil
}

// AcquireTemporary reserves one free credential for a single request. Temporary
// reservations share the exclusion map but never enter persistent lease state.
func (s *Store) AcquireTemporary(owner, policy string, allowed map[string]bool, candidates []Candidate, now time.Time) (Lease, func(), error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.check(policy, now); err != nil {
return Lease{}, nil, err
}
var old *Lease
for _, l := range s.leases {
if l.Owner == owner && !l.Temporary {
if !allowed[l.Group] {
return Lease{}, nil, ErrDenied
}
if l.Active != 0 {
return Lease{}, nil, ErrBusy
}
old = l
break
}
}
ignore := ""
if old != nil {
ignore = old.ID
}
for _, c := range candidates {
if c.Credential == "" || !allowed[c.Group] || s.occupied(c, ignore) {
continue
}
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return Lease{}, nil, err
}
next := &Lease{ID: hex.EncodeToString(id), Owner: owner, Group: c.Group, Credential: c.Credential, Policy: policy, Started: now, Expires: now, Active: 1, Temporary: true}
s.leases[next.ID] = next
if old != nil {
delete(s.leases, old.ID)
if err := s.save(); err != nil {
delete(s.leases, next.ID)
s.leases[old.ID] = old
return Lease{}, nil, err
}
}
return *next, s.releaser(next.ID), nil
}
return Lease{}, nil, ErrBusy
}
114 changes: 114 additions & 0 deletions internal/poollease/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,117 @@ func TestReplacementPersistenceFailureRestoresOldLease(t *testing.T) {
t.Fatal(rows)
}
}

func TestTemporaryReservationsAreExclusiveAndNeverPersisted(t *testing.T) {
s := testStore(t)
now := time.Now()
allowed := map[string]bool{"a": true}
c := []Candidate{{"a", "one"}, {"a", "two"}, {"a", "three"}}
fixed, releaseFixed, err := s.Acquire("ordinary", "policy", allowed, c, now)
if err != nil {
t.Fatal(err)
}
defer releaseFixed()
before, _ := os.ReadFile(s.path)
first, done, err := s.AcquireTemporary("admin", "policy", allowed, c, now)
if err != nil {
t.Fatal(err)
}
second, done2, err := s.AcquireTemporary("admin", "policy", allowed, c, now)
if err != nil {
t.Fatal(err)
}
if !first.Temporary || first.Credential == fixed.Credential || second.Credential == first.Credential {
t.Fatal(first, second, fixed)
}
if _, _, err = s.Acquire("other", "policy", allowed, c, now); !errors.Is(err, ErrBusy) {
t.Fatal("stole temporary account", err)
}
if _, _, err = s.AcquireTemporary("admin", "policy", allowed, c, now); !errors.Is(err, ErrBusy) {
t.Fatal("stole occupied account", err)
}
if _, ok, _ := s.Lookup("admin", "policy", now); ok {
t.Fatal("temporary reservation became user lease")
}
hold, ok := s.Hold(first.ID)
if !ok {
t.Fatal("cannot hold temporary")
}
done()
if _, ok, _ := s.LookupID(first.ID, "policy", now.Add(2*time.Hour)); !ok {
t.Fatal("released before producer completed")
}
hold()
done2()
done()
if _, ok, _ := s.LookupID(first.ID, "policy", now); ok {
t.Fatal("temporary leaked")
}
after, _ := os.ReadFile(s.path)
if string(before) != string(after) {
t.Fatal("temporary state written to disk")
}
if rows := s.Snapshot(now); len(rows) != 1 || rows[0].ID != fixed.ID {
t.Fatal(rows)
}
}

func TestTemporaryMigrationAndSaveFailure(t *testing.T) {
s := testStore(t)
now := time.Now()
a := map[string]bool{"a": true}
c := []Candidate{{"a", "one"}}
old, done, _ := s.Acquire("admin", "policy", a, c, now)
if _, _, err := s.AcquireTemporary("admin", "policy", a, c, now); !errors.Is(err, ErrBusy) {
t.Fatal("migrated active lease", err)
}
done()
original := s.path
s.path = filepath.Join(t.TempDir(), "missing", "state")
if _, _, err := s.AcquireTemporary("admin", "policy", a, c, now); err == nil {
t.Fatal("migration ignored persistence failure")
}
s.path = original
if l, ok, _ := s.Lookup("admin", "policy", now); !ok || l.ID != old.ID {
t.Fatal("lost original lease")
}
next, release, err := s.AcquireTemporary("admin", "policy", a, c, now)
if err != nil {
t.Fatal(err)
}
if !next.Temporary || next.Credential != old.Credential {
t.Fatal(next)
}
var state struct{ Leases []Lease }
raw, _ := os.ReadFile(s.path)
json.Unmarshal(raw, &state)
if len(state.Leases) != 0 {
t.Fatal("old administrator lease still persisted")
}
release()
if len(s.Snapshot(now)) != 0 {
t.Fatal("temporary lease remains")
}
}

func TestTemporaryReplacementStaysTemporary(t *testing.T) {
s := testStore(t)
now := time.Now()
c := []Candidate{{"a", "one"}, {"a", "two"}}
old, done, err := s.AcquireTemporary("admin", "p", map[string]bool{"a": true}, c, now)
if err != nil {
t.Fatal(err)
}
next, release, err := s.Replace(old, c, now.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if !next.Temporary || next.Credential == old.Credential {
t.Fatal(next)
}
done()
release()
if len(s.Snapshot(now)) != 0 {
t.Fatal("replaced temporary reservation leaked")
}
}
28 changes: 23 additions & 5 deletions sdk/cliproxy/auth/pool_leases.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,20 @@ func (m *Manager) scopeWithLease(ctx context.Context, cfg *config.Config, scope
if err != nil {
return scope, nil
}
lease, ok, err := store.Lookup(owner, runtimeLeaseRevision(cfg), time.Now())
binding, _ := ctx.Value(requestPoolLeaseKey{}).(*requestPoolLease)
var lease poollease.Lease
var ok bool
if binding != nil {
lease, ok, err = store.LookupID(binding.Lease.ID, runtimeLeaseRevision(cfg), time.Now())
} else if sdkaccess.GetGatewayIdentity(ctx).User == "1" {
return scope, nil
} else {
lease, ok, err = store.Lookup(owner, runtimeLeaseRevision(cfg), time.Now())
}
if err != nil {
return nil, err
}
binding, _ := ctx.Value(requestPoolLeaseKey{}).(*requestPoolLease)
if binding != nil && (!ok || binding.Owner != owner || binding.Lease.ID != lease.ID) {
if binding != nil && (!ok || binding.Owner != owner || lease.Owner != owner || binding.Lease.ID != lease.ID) {
return nil, leaseError("pool_lease_expired", "pool lease is no longer valid", 403)
}
if !ok {
Expand All @@ -126,7 +134,7 @@ func (m *Manager) scopeWithLease(ctx context.Context, cfg *config.Config, scope
if !scope.AuthorizesGroup(lease.Group) {
return nil, leaseError("pool_access_denied", "current lease is outside key permissions", 403)
}
if !time.Now().Before(lease.Expires) {
if !lease.Temporary && !time.Now().Before(lease.Expires) {
return nil, leaseError("pool_lease_expired", "pool lease expired; finish the current request before allocating again", 503)
}
return scope.WithLease(lease.Group, lease.ID, lease.Credential), nil
Expand All @@ -153,6 +161,10 @@ func (m *Manager) beginPoolLease(ctx context.Context, providers []string, req co
if store == nil {
return ctx, noop, leaseError("pool_unavailable", "no lease pools configured", 503)
}
temporary := sdkaccess.GetGatewayIdentity(ctx).User == "1"
if temporary && (gjson.GetBytes(req.Payload, "previous_response_id").String() != "" || gjson.GetBytes(opts.OriginalRequest, "previous_response_id").String() != "") {
return ctx, noop, leaseError("pool_temporary_session_unsupported", "temporary account access requires full conversation history; previous_response_id is not supported", http.StatusConflict)
}
if previous, exists, e := store.Lookup(owner, runtimeLeaseRevision(cfg), time.Now()); e != nil {
return ctx, noop, e
} else if (!exists || previous.LegacyGroup || previous.Reassigned) && (gjson.GetBytes(req.Payload, "previous_response_id").String() != "" || gjson.GetBytes(opts.OriginalRequest, "previous_response_id").String() != "") {
Expand All @@ -163,7 +175,13 @@ func (m *Manager) beginPoolLease(ctx context.Context, providers []string, req co
for _, group := range scope.LeaseGroups() {
allowed[group] = true
}
lease, release, err := store.Acquire(owner, runtimeLeaseRevision(cfg), allowed, candidates, time.Now())
var lease poollease.Lease
var release func()
if temporary {
lease, release, err = store.AcquireTemporary(owner, runtimeLeaseRevision(cfg), allowed, candidates, time.Now())
} else {
lease, release, err = store.Acquire(owner, runtimeLeaseRevision(cfg), allowed, candidates, time.Now())
}
if err != nil {
if errors.Is(err, poollease.ErrDenied) {
return ctx, noop, leaseError("pool_access_denied", "current lease is outside key permissions", 403)
Expand Down
Loading
Loading