feat(opencode): 用量窗口支持同 Key 组共享、活动防抖与超窗强刷

与 Ollama Cloud usage 对齐:OpenCode Go 作为同 Key 聚合订阅,
多个 openai/apikey/base_url=opencode.ai/zen/go/v1 账号共享同一份
用量状态与刷新节奏:

- 组共享:组指纹 sha256("opencode.ai\0"+api_key);auto_refresh 开关
  与 snapshot 按组写(事务 + FOR NO KEY UPDATE + 锚点 CAS + 纯合并,
  写 snapshot 不会抹掉开关);列表/详情经 ResolveAccounts 组内共享;
  账号换 Key → IdentityChanged → 组级关闭 auto_refresh 并清快照
- 活动防抖 + 超窗强刷:settings 新增 debounce_minutes(默认 1,
  1-60,必须小于 interval_minutes);模型请求活动经网关 5 处调用点
  驱动刷新,due = min(lastUsed+debounce, fetchedAt+maxWait),
  成功路径有 5 分钟最小抓取间隔,失败路径退避优先
- 手动刷新限频 10s→30s 且按组;singleflight/RunDue 按组去重;
  ListDue 在 SQL 内按组算 due(CTE 分组取组内 MAX(last_used_at))

验证:go build + 五包回归 OK;新增组共享/三态 due/组单飞/
IdentityChanged/debounce 校验单测全绿;vitest 11 例全绿;
vue-tsc 零错误。
This commit is contained in:
alfadb
2026-09-23 15:26:35 +08:00
parent 6624947b62
commit ae2735cdb6
12 changed files with 1270 additions and 147 deletions
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"reflect"
"sync"
"sync/atomic"
"testing"
@@ -22,12 +23,17 @@ const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6,"
type openCodeGoUsageTestRepo struct {
AccountRepository
mu sync.Mutex
accounts map[int64]*Account
due []Account
mu sync.Mutex
accounts map[int64]*Account
due []Account
groupResolveCalls atomic.Int64
getByIDCalls atomic.Int64
disableAutoCalls atomic.Int64
disableAutoAttempt atomic.Int64
}
func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) {
r.getByIDCalls.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
account := r.accounts[id]
@@ -52,35 +58,109 @@ func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*A
return result, nil
}
func (r *openCodeGoUsageTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) {
r.groupResolveCalls.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
wanted := make(map[string]struct{}, len(anchors))
for _, anchor := range anchors {
if fingerprint, ok := openCodeGoUsageGroupFingerprint(anchor); ok {
wanted[fingerprint] = struct{}{}
}
}
result := make([]Account, 0, len(r.accounts))
for _, account := range r.accounts {
fingerprint, ok := openCodeGoUsageGroupFingerprint(account)
if _, match := wanted[fingerprint]; !ok || !match {
continue
}
result = append(result, cloneOpenCodeGoUsageTestAccount(*account))
}
return result, nil
}
func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error {
r.mu.Lock()
defer r.mu.Unlock()
account := r.accounts[expected.ID]
if account == nil {
return ErrAccountNotFound
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
if account.Extra == nil {
account.Extra = make(map[string]any)
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled
}
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled
return nil
}
func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error {
r.mu.Lock()
defer r.mu.Unlock()
account := r.accounts[expected.ID]
if account == nil {
return ErrAccountNotFound
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
if account.Extra == nil {
account.Extra = make(map[string]any)
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot
}
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot
return nil
}
func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) {
func (r *openCodeGoUsageTestRepo) DisableOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account) error {
r.disableAutoAttempt.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = false
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
}
r.disableAutoCalls.Add(1)
return nil
}
func (r *openCodeGoUsageTestRepo) openCodeGoGroupMembersLocked(expected *Account) ([]*Account, error) {
anchor := r.accounts[expected.ID]
if !sameOpenCodeGoUsageTestIdentity(anchor, expected) {
return nil, ErrOpenCodeGoUsageIdentityChanged
}
fingerprint, ok := openCodeGoUsageGroupFingerprint(expected)
if !ok {
return nil, ErrOpenCodeGoUsageAccountInvalid
}
members := make([]*Account, 0, len(r.accounts))
for _, account := range r.accounts {
candidate, valid := openCodeGoUsageGroupFingerprint(account)
if valid && candidate == fingerprint {
if account.Extra == nil {
account.Extra = make(map[string]any)
}
members = append(members, account)
}
}
return members, nil
}
func applyOpenCodeGoUsageTestManagedExtra(account, source *Account) {
for _, key := range []string{OpenCodeGoUsageAutoRefreshExtraKey, OpenCodeGoUsageSnapshotExtraKey} {
delete(account.Extra, key)
if value, ok := source.Extra[key]; ok {
account.Extra[key] = value
}
}
}
func sameOpenCodeGoUsageTestIdentity(left, right *Account) bool {
return left != nil && right != nil && left.Platform == right.Platform && left.Type == right.Type &&
reflect.DeepEqual(left.Credentials, right.Credentials) && reflect.DeepEqual(left.ProxyID, right.ProxyID)
}
func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, _, _ time.Duration, limit int) ([]Account, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.due) > 0 {
@@ -107,13 +187,14 @@ func cloneOpenCodeGoUsageTestAccount(account Account) Account {
}
type openCodeGoUsageHTTPStub struct {
status int
body []byte
header http.Header
calls atomic.Int64
lastRequest *http.Request
lastProxy string
mu sync.Mutex
status int
body []byte
header http.Header
calls atomic.Int64
beforeResponse func(*http.Request)
lastRequest *http.Request
lastProxy string
mu sync.Mutex
}
func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) {
@@ -122,6 +203,9 @@ func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64
s.lastRequest = req
s.lastProxy = proxyURL
s.mu.Unlock()
if s.beforeResponse != nil {
s.beforeResponse(req)
}
status := s.status
if status == 0 {
status = http.StatusOK
@@ -359,11 +443,264 @@ func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) {
}
func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) {
debounce := time.Minute
maxWait := time.Hour
now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC)
fetched := now.Add(-30 * time.Minute)
ptr := func(ts time.Time) *time.Time { return &ts }
require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due")
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due")
okSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(fetched),
LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait),
}
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success")
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at")
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed")
require.True(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce")
// Continuous requests: last used is now, but max-wait from old fetch forces due.
oldFetched := now.Add(-2 * time.Hour)
oldSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched),
LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait),
}
require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue")
// First request after a very old snapshot is immediately due because fetched+maxWait is past.
require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate")
failSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusFailed, FetchedAt: ptr(fetched),
LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute),
}
require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request")
require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff")
failSnap.NextRefreshAt = now.Add(-time.Second)
require.True(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request")
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now,
}, nil, now, debounce, maxWait), "ok without fetched_at fails open")
}
// The success path stopped consulting next_refresh_at, which is where
// nextOpenCodeGoUsageDelay used to apply the minimum interval. Activity may pull
// a refresh forward only as far as that floor, otherwise request traffic spaced
// just wider than the debounce drives the group's outbound rate far above the
// pre-existing minimum.
func TestOpenCodeGoUsageAutoRefreshDueAtHonoursMinFetchInterval(t *testing.T) {
debounce := time.Minute
maxWait := time.Hour
now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC)
ptr := func(ts time.Time) *time.Time { return &ts }
// Debounce elapsed, but the last successful fetch is inside the floor.
recent := now.Add(-2 * time.Minute)
recentSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(recent), LastAttemptAt: recent,
}
dueAt, ok := openCodeGoUsageAutoRefreshDueAt(recentSnap, ptr(now.Add(-time.Minute)), debounce, maxWait)
require.True(t, ok)
require.Equal(t, recent.Add(OpenCodeGoUsageMinFetchInterval), dueAt,
"due time must be clamped to fetched_at + min fetch interval")
require.False(t, openCodeGoUsageIsAutoRefreshDue(recentSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait),
"debounce alone must not refresh within the min fetch interval")
// Once the floor has passed the debounce governs again.
atFloor := now.Add(-OpenCodeGoUsageMinFetchInterval)
floorSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(atFloor), LastAttemptAt: atFloor,
}
require.True(t, openCodeGoUsageIsAutoRefreshDue(floorSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait),
"past the floor a quiet debounce window is due")
// The floor never delays a refresh that max-wait has already forced.
oldFetched := now.Add(-2 * time.Hour)
oldSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), LastAttemptAt: oldFetched,
}
dueAt, ok = openCodeGoUsageAutoRefreshDueAt(oldSnap, ptr(now), debounce, maxWait)
require.True(t, ok)
require.Equal(t, oldFetched.Add(maxWait), dueAt, "max-wait due time must not be pushed out by the floor")
}
func TestOpenCodeGoUsageGroupSharesStateAcrossSiblings(t *testing.T) {
source := openCodeGoUsageAccount(71)
source.Credentials["api_key"] = "shared-key"
source.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
source.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK,
Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}},
}
source.UpdatedAt = time.Now().Add(-time.Minute)
sibling := openCodeGoUsageAccount(72)
sibling.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"}
sibling.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
sibling.UpdatedAt = time.Now()
different := openCodeGoUsageAccount(73)
different.Credentials["api_key"] = "different-key"
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{
source.ID: source, sibling.ID: sibling, different.ID: different,
}}
svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{})
state, err := svc.GetState(context.Background(), sibling.ID)
require.NoError(t, err)
require.True(t, state.Eligible)
require.True(t, state.AutoRefreshEnabled)
require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent)
differentState, err := svc.GetState(context.Background(), different.ID)
require.NoError(t, err)
require.False(t, differentState.AutoRefreshEnabled)
require.Nil(t, differentState.Snapshot)
newSibling := openCodeGoUsageAccount(74)
newSibling.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "shared-key"}
repo.mu.Lock()
repo.accounts[newSibling.ID] = newSibling
repo.mu.Unlock()
newState, err := svc.GetState(context.Background(), newSibling.ID)
require.NoError(t, err)
require.True(t, newState.AutoRefreshEnabled)
require.Equal(t, state.Snapshot, newState.Snapshot)
before := repo.groupResolveCalls.Load()
require.NoError(t, svc.ResolveOpenCodeGoUsageAccounts(context.Background(), []*Account{source, sibling, different, newSibling}))
require.Equal(t, before+1, repo.groupResolveCalls.Load(), "one list batch must issue one group lookup")
}
func TestOpenCodeGoUsageSetAutoRefreshAndSnapshotAreGroupScoped(t *testing.T) {
first := openCodeGoUsageAccount(81)
first.Credentials["api_key"] = "shared-key"
second := openCodeGoUsageAccount(82)
second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"}
different := openCodeGoUsageAccount(83)
different.Credentials["api_key"] = "different-key"
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{
first.ID: first, second.ID: second, different.ID: different,
}}
svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{})
state, err := svc.SetAutoRefresh(context.Background(), second.ID, true)
require.NoError(t, err)
require.True(t, state.AutoRefreshEnabled)
require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.NotContains(t, different.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
// A snapshot write must not wipe the auto-refresh switch (pure merge).
now := time.Now().UTC()
require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, now))
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{}, now))
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(-time.Minute)}, now))
require.False(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(time.Minute)}, now))
_, err = svc.Refresh(context.Background(), first.ID)
require.NoError(t, err)
require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh")
require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh on siblings")
require.NotNil(t, decodeOpenCodeGoUsageSnapshot(second.Extra), "snapshot must be shared with siblings")
require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra))
require.NotNil(t, now)
}
func TestOpenCodeGoUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *testing.T) {
first := openCodeGoUsageAccount(91)
first.Credentials["api_key"] = "shared-key"
first.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
second := openCodeGoUsageAccount(92)
second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"}
second.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{
accounts: map[int64]*Account{first.ID: first, second.ID: second},
due: []Account{*first, *second},
}
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`,
}}
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture), beforeResponse: func(*http.Request) {
once.Do(func() { close(started) })
<-release
}}
svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo)
errs := make(chan error, 2)
go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }()
<-started
// The first caller is now parked in the stub, having loaded the account twice
// (once to build the group key, once inside the singleflight function).
loadsBeforeSecond := repo.getByIDCalls.Load()
go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }()
// Only release the first caller once the second one has loaded its own
// account, which happens immediately before it joins the singleflight group.
require.Eventually(t, func() bool {
return repo.getByIDCalls.Load() > loadsBeforeSecond
}, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released")
close(release)
require.NoError(t, <-errs)
require.NoError(t, <-errs)
require.Equal(t, int64(1), upstream.calls.Load())
require.NotNil(t, decodeOpenCodeGoUsageSnapshot(first.Extra))
require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra))
delete(first.Extra, OpenCodeGoUsageSnapshotExtraKey)
delete(second.Extra, OpenCodeGoUsageSnapshotExtraKey)
upstream.beforeResponse = nil
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(2), upstream.calls.Load(), "RunDue must issue one request for the shared group")
}
func TestOpenCodeGoUsageRefreshRejectsGroupChangeBeforeUpstreamRequest(t *testing.T) {
account := openCodeGoUsageAccount(94)
base := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{account.ID: account}}
repo := &openCodeGoRefreshPreflightIdentityChangeRepo{openCodeGoUsageTestRepo: base}
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil))
t.Cleanup(svc.Stop)
_, err := svc.Refresh(context.Background(), account.ID)
require.ErrorIs(t, err, ErrOpenCodeGoUsageIdentityChanged)
require.Zero(t, upstream.calls.Load())
require.NotContains(t, account.Extra, OpenCodeGoUsageSnapshotExtraKey)
}
type openCodeGoRefreshPreflightIdentityChangeRepo struct {
*openCodeGoUsageTestRepo
getCalls atomic.Int64
}
func (r *openCodeGoRefreshPreflightIdentityChangeRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
if r.getCalls.Add(1) == 2 {
r.mu.Lock()
r.accounts[id].Credentials["api_key"] = "rotated-before-refresh"
r.mu.Unlock()
}
return r.openCodeGoUsageTestRepo.GetByID(ctx, id)
}
func TestOpenCodeGoUsageRunnerDisablesAutoRefreshAfterIdentityError(t *testing.T) {
account := openCodeGoUsageAccount(14)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
missingProxyID := int64(99)
account.ProxyID = &missingProxyID
account.Proxy = nil
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{14: account}}
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`,
}}
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo)
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(1), repo.disableAutoCalls.Load())
require.Equal(t, false, account.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.Zero(t, upstream.calls.Load())
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(1), repo.disableAutoCalls.Load())
require.Zero(t, upstream.calls.Load())
}
func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) {
@@ -438,6 +775,43 @@ func TestIsOpenCodeGoUsageAccount(t *testing.T) {
require.False(t, IsOpenCodeGoUsageAccount(account))
}
func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) {
first := openCodeGoUsageAccount(1)
first.Credentials["api_key"] = "shared-key"
second := openCodeGoUsageAccount(2)
second.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"}
third := openCodeGoUsageAccount(3)
third.Credentials["api_key"] = "other-key"
firstFP, firstOK := openCodeGoUsageGroupFingerprint(first)
secondFP, secondOK := openCodeGoUsageGroupFingerprint(second)
thirdFP, thirdOK := openCodeGoUsageGroupFingerprint(third)
require.True(t, firstOK)
require.True(t, secondOK)
require.True(t, thirdOK)
require.Equal(t, firstFP, secondFP, "same api_key across base_url variants must share a group")
require.NotEqual(t, firstFP, thirdFP, "different api_key must not share a group")
// ineligible accounts have no fingerprint
account := openCodeGoUsageAccount(4)
account.Credentials["base_url"] = "https://opencode.ai/v1"
_, ok := openCodeGoUsageGroupFingerprint(account)
require.False(t, ok)
}
func TestScheduleOpenCodeGoUsageActivityOnlyForOpenCode(t *testing.T) {
deferred := &DeferredService{}
openCode := openCodeGoUsageAccount(1)
openCode.Credentials["api_key"] = "k"
other := openCodeGoUsageAccount(2)
other.Credentials["base_url"] = "https://opencode.ai/v1"
scheduleOpenCodeGoUsageActivity(deferred, openCode)
scheduleOpenCodeGoUsageActivity(deferred, other)
scheduleOpenCodeGoUsageActivity(nil, openCode)
scheduleOpenCodeGoUsageActivity(deferred, nil)
}
func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) {
repo := &upstreamBillingProbeSettingRepo{}
settingsService := NewSettingService(repo, nil)
@@ -445,6 +819,7 @@ func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) {
require.NoError(t, err)
require.False(t, settings.Enabled)
require.Equal(t, 15, settings.IntervalMinutes)
require.Equal(t, 1, settings.DebounceMinutes)
// below the minimum
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1})
@@ -452,13 +827,40 @@ func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) {
// above the maximum
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000})
require.Error(t, err)
// debounce out of range
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 61})
require.Error(t, err)
// DebounceMinutes=0 (legacy omit) defaults to 1 on write.
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 0})
require.NoError(t, err)
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.Equal(t, 1, settings.DebounceMinutes)
// valid update round-trips
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30})
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 2})
require.NoError(t, err)
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.Enabled)
require.Equal(t, 30, settings.IntervalMinutes)
require.Equal(t, 2, settings.DebounceMinutes)
// debounce >= interval would make the debounce term unreachable in
// min(lastUsed+debounce, fetchedAt+maxWait), silently ignoring the operator's
// setting, so it is rejected rather than accepted and dropped.
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 5})
require.Error(t, err, "debounce equal to interval must be rejected")
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 60})
require.Error(t, err, "debounce greater than interval must be rejected")
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 6, DebounceMinutes: 5})
require.NoError(t, err, "debounce below interval stays valid")
// Legacy JSON without debounce_minutes defaults to 1.
repo.values[SettingKeyOpenCodeGoUsageSettings] = `{"enabled":true,"interval_minutes":45}`
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.Equal(t, 45, settings.IntervalMinutes)
require.Equal(t, 1, settings.DebounceMinutes)
}
func TestOpenCodeGoUsageStateFromAccount(t *testing.T) {