upstream_billing_probe_test.go 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "reflect"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  16. "github.com/stretchr/testify/require"
  17. )
  18. type upstreamBillingProbeAccountRepo struct {
  19. AccountRepository
  20. mu sync.Mutex
  21. accounts map[int64]*Account
  22. updates map[int64][]map[string]any
  23. bulkUpdates []AccountBulkUpdate
  24. }
  25. type staleDueUpstreamBillingProbeAccountRepo struct {
  26. *upstreamBillingProbeAccountRepo
  27. due []Account
  28. }
  29. func (r *staleDueUpstreamBillingProbeAccountRepo) ListDueUpstreamBillingProbeAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) {
  30. if limit < len(r.due) {
  31. return append([]Account(nil), r.due[:limit]...), nil
  32. }
  33. return append([]Account(nil), r.due...), nil
  34. }
  35. func (r *upstreamBillingProbeAccountRepo) Create(_ context.Context, account *Account) error {
  36. r.mu.Lock()
  37. defer r.mu.Unlock()
  38. if r.accounts == nil {
  39. r.accounts = make(map[int64]*Account)
  40. }
  41. if account.ID == 0 {
  42. account.ID = int64(len(r.accounts) + 1)
  43. }
  44. r.accounts[account.ID] = account
  45. return nil
  46. }
  47. func (r *upstreamBillingProbeAccountRepo) Update(_ context.Context, account *Account) error {
  48. r.mu.Lock()
  49. defer r.mu.Unlock()
  50. r.accounts[account.ID] = account
  51. return nil
  52. }
  53. func (r *upstreamBillingProbeAccountRepo) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
  54. r.mu.Lock()
  55. defer r.mu.Unlock()
  56. r.bulkUpdates = append(r.bulkUpdates, updates)
  57. return int64(len(ids)), nil
  58. }
  59. func (r *upstreamBillingProbeAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) {
  60. r.mu.Lock()
  61. defer r.mu.Unlock()
  62. account := r.accounts[id]
  63. if account == nil {
  64. return nil, ErrAccountNotFound
  65. }
  66. clone := *account
  67. clone.Credentials = mergeMap(nil, account.Credentials)
  68. clone.Extra = mergeMap(nil, account.Extra)
  69. return &clone, nil
  70. }
  71. func (r *upstreamBillingProbeAccountRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) {
  72. r.mu.Lock()
  73. defer r.mu.Unlock()
  74. result := make([]*Account, 0, len(ids))
  75. for _, id := range ids {
  76. if account := r.accounts[id]; account != nil {
  77. result = append(result, account)
  78. }
  79. }
  80. return result, nil
  81. }
  82. func (r *upstreamBillingProbeAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  83. r.mu.Lock()
  84. defer r.mu.Unlock()
  85. account := r.accounts[id]
  86. if account == nil {
  87. return ErrAccountNotFound
  88. }
  89. if account.Extra == nil {
  90. account.Extra = make(map[string]any)
  91. }
  92. for key, value := range updates {
  93. account.Extra[key] = value
  94. }
  95. if r.updates == nil {
  96. r.updates = make(map[int64][]map[string]any)
  97. }
  98. r.updates[id] = append(r.updates[id], updates)
  99. return nil
  100. }
  101. func (r *upstreamBillingProbeAccountRepo) UpdateUpstreamBillingProbeSnapshot(
  102. _ context.Context,
  103. expected *Account,
  104. snapshot *UpstreamBillingProbeSnapshot,
  105. rateMultiplier *float64,
  106. ) error {
  107. r.mu.Lock()
  108. defer r.mu.Unlock()
  109. account := r.accounts[expected.ID]
  110. if account == nil || account.Platform != expected.Platform || account.Type != expected.Type || !reflect.DeepEqual(account.Credentials, expected.Credentials) {
  111. return ErrUpstreamBillingProbeIdentityChanged
  112. }
  113. if account.Extra == nil {
  114. account.Extra = make(map[string]any)
  115. }
  116. account.Extra[UpstreamBillingProbeExtraKey] = snapshot
  117. if snapshot.Status == UpstreamBillingProbeStatusOK &&
  118. rateMultiplier != nil &&
  119. upstreamBillingRateSyncEnabled(account) {
  120. value := *rateMultiplier
  121. account.RateMultiplier = &value
  122. }
  123. return nil
  124. }
  125. func (r *upstreamBillingProbeAccountRepo) FindByExtraField(_ context.Context, key string, value any) ([]Account, error) {
  126. r.mu.Lock()
  127. defer r.mu.Unlock()
  128. result := make([]Account, 0)
  129. for _, account := range r.accounts {
  130. if account.Extra != nil && account.Extra[key] == value {
  131. result = append(result, *account)
  132. }
  133. }
  134. return result, nil
  135. }
  136. type upstreamBillingProbeSettingRepo struct {
  137. SettingRepository
  138. mu sync.Mutex
  139. values map[string]string
  140. }
  141. type upstreamBillingProbeHTTPStub struct {
  142. calls atomic.Int64
  143. active atomic.Int64
  144. maxActive atomic.Int64
  145. beforeResponse func()
  146. }
  147. func (u *upstreamBillingProbeHTTPStub) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  148. u.calls.Add(1)
  149. active := u.active.Add(1)
  150. defer u.active.Add(-1)
  151. for {
  152. peak := u.maxActive.Load()
  153. if active <= peak || u.maxActive.CompareAndSwap(peak, active) {
  154. break
  155. }
  156. }
  157. if u.beforeResponse != nil {
  158. u.beforeResponse()
  159. }
  160. return &http.Response{
  161. StatusCode: http.StatusOK,
  162. Header: http.Header{"Content-Type": []string{"application/json"}},
  163. Body: io.NopCloser(strings.NewReader(`{
  164. "object":"sub2api.key_billing",
  165. "schema_version":1,
  166. "billing_scope":"token",
  167. "group_rate_multiplier":0.8,
  168. "resolved_rate_multiplier":0.8,
  169. "peak_rate_enabled":false,
  170. "effective_rate_multiplier":0.8,
  171. "observed_at":"2026-07-13T01:00:00Z"
  172. }`)),
  173. }, nil
  174. }
  175. func (u *upstreamBillingProbeHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  176. return u.Do(req, proxyURL, accountID, accountConcurrency)
  177. }
  178. func (r *upstreamBillingProbeSettingRepo) GetValue(_ context.Context, key string) (string, error) {
  179. r.mu.Lock()
  180. defer r.mu.Unlock()
  181. value, ok := r.values[key]
  182. if !ok {
  183. return "", ErrSettingNotFound
  184. }
  185. return value, nil
  186. }
  187. func (r *upstreamBillingProbeSettingRepo) Set(_ context.Context, key, value string) error {
  188. r.mu.Lock()
  189. defer r.mu.Unlock()
  190. if r.values == nil {
  191. r.values = make(map[string]string)
  192. }
  193. r.values[key] = value
  194. return nil
  195. }
  196. func newUpstreamBillingProbeTestService(
  197. repo AccountRepository,
  198. upstream HTTPUpstream,
  199. settingRepo SettingRepository,
  200. ) *UpstreamBillingProbeService {
  201. cfg := &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
  202. Enabled: false,
  203. AllowInsecureHTTP: true,
  204. }}}
  205. accountTestService := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: cfg}
  206. return NewUpstreamBillingProbeService(repo, accountTestService, NewSettingService(settingRepo, cfg))
  207. }
  208. func TestUpstreamBillingProbeSettingsDefaultsAndValidation(t *testing.T) {
  209. repo := &upstreamBillingProbeSettingRepo{}
  210. settingsService := NewSettingService(repo, &config.Config{})
  211. settings, err := settingsService.GetUpstreamBillingProbeSettings(context.Background())
  212. require.NoError(t, err)
  213. require.True(t, settings.Enabled)
  214. require.Equal(t, 30, settings.IntervalMinutes)
  215. err = settingsService.SetUpstreamBillingProbeSettings(context.Background(), &UpstreamBillingProbeSettings{
  216. Enabled: false,
  217. IntervalMinutes: 4,
  218. })
  219. require.Error(t, err)
  220. require.Contains(t, err.Error(), "interval_minutes must be between 5 and 1440")
  221. err = settingsService.SetUpstreamBillingProbeSettings(context.Background(), &UpstreamBillingProbeSettings{
  222. Enabled: false,
  223. IntervalMinutes: 60,
  224. })
  225. require.NoError(t, err)
  226. settings, err = settingsService.GetUpstreamBillingProbeSettings(context.Background())
  227. require.NoError(t, err)
  228. require.False(t, settings.Enabled)
  229. require.Equal(t, 60, settings.IntervalMinutes)
  230. repo.values[SettingKeyUpstreamBillingProbeSettings] = `{"interval_minutes":45}`
  231. settings, err = settingsService.GetUpstreamBillingProbeSettings(context.Background())
  232. require.NoError(t, err)
  233. require.True(t, settings.Enabled)
  234. require.Equal(t, 45, settings.IntervalMinutes)
  235. repo.values[SettingKeyUpstreamBillingProbeSettings] = `{"enabled":false}`
  236. settings, err = settingsService.GetUpstreamBillingProbeSettings(context.Background())
  237. require.NoError(t, err)
  238. require.False(t, settings.Enabled)
  239. require.Equal(t, 30, settings.IntervalMinutes)
  240. repo.values[SettingKeyUpstreamBillingProbeSettings] = `{"enabled":`
  241. settings, err = settingsService.GetUpstreamBillingProbeSettings(context.Background())
  242. require.ErrorContains(t, err, "parse upstream billing probe settings")
  243. require.Nil(t, settings)
  244. }
  245. func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) {
  246. initialRate := 0.25
  247. account := &Account{
  248. ID: 17,
  249. Platform: PlatformOpenAI,
  250. Type: AccountTypeAPIKey,
  251. Status: StatusActive,
  252. Concurrency: 2,
  253. Credentials: map[string]any{
  254. "api_key": "sk-sensitive",
  255. "base_url": "https://upstream.example/v1",
  256. },
  257. Extra: map[string]any{
  258. UpstreamBillingProbeEnabledExtraKey: true,
  259. UpstreamBillingRateSyncEnabledExtraKey: true,
  260. },
  261. RateMultiplier: &initialRate,
  262. }
  263. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  264. upstream := &httpUpstreamRecorder{resp: &http.Response{
  265. StatusCode: http.StatusOK,
  266. Header: http.Header{"Content-Type": []string{"application/json"}},
  267. Body: io.NopCloser(strings.NewReader(`{
  268. "object":"sub2api.key_billing",
  269. "schema_version":1,
  270. "billing_scope":"token",
  271. "group_rate_multiplier":0.8,
  272. "user_rate_multiplier":0.6,
  273. "resolved_rate_multiplier":0.6,
  274. "peak_rate_enabled":true,
  275. "peak_start":"09:00",
  276. "peak_end":"18:00",
  277. "peak_rate_multiplier":1.5,
  278. "applied_peak_multiplier":1.5,
  279. "effective_rate_multiplier":0.9,
  280. "timezone":"Asia/Shanghai",
  281. "observed_at":"2026-07-13T01:00:00Z",
  282. "unexpected_secret":"must-not-persist"
  283. }`)),
  284. }}
  285. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  286. fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
  287. svc.now = func() time.Time { return fixedNow }
  288. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  289. require.NoError(t, err)
  290. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  291. require.Equal(t, 0.9, snapshot.Data["effective_rate_multiplier"])
  292. require.NotContains(t, snapshot.Data, "unexpected_secret")
  293. require.NotNil(t, snapshot.ReceivedAt)
  294. require.Equal(t, fixedNow, *snapshot.ReceivedAt)
  295. require.NotNil(t, snapshot.FreshUntil)
  296. require.Equal(t, fixedNow.Add(time.Hour), *snapshot.FreshUntil)
  297. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
  298. require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
  299. // 写回的是不含高峰因子的 resolved 倍率(0.6),不是探测那一刻含高峰的
  300. // effective 倍率(0.9)——否则一个探测周期的峰值会被冻结进静态列。
  301. require.NotNil(t, account.RateMultiplier)
  302. require.Equal(t, 0.6, *account.RateMultiplier)
  303. require.NotNil(t, snapshot.SyncedRateMultiplier)
  304. require.Equal(t, 0.6, *snapshot.SyncedRateMultiplier)
  305. require.Equal(t, "https://upstream.example/v1/sub2api/billing", upstream.lastReq.URL.String())
  306. require.Equal(t, http.MethodGet, upstream.lastReq.Method)
  307. require.Equal(t, "Bearer sk-sensitive", upstream.lastReq.Header.Get("Authorization"))
  308. require.True(t, HTTPUpstreamRedirectsDisabled(upstream.lastReq.Context()))
  309. persisted := decodeUpstreamBillingProbeSnapshot(account.Extra)
  310. require.NotNil(t, persisted)
  311. require.Equal(t, snapshot.Status, persisted.Status)
  312. }
  313. func TestUpstreamBillingProbeAdaptiveCNUsesChatProtocolBaseURL(t *testing.T) {
  314. account := &Account{
  315. ID: 18,
  316. Platform: PlatformKimi,
  317. Type: AccountTypeAPIKey,
  318. Status: StatusActive,
  319. Concurrency: 1,
  320. Credentials: map[string]any{
  321. "api_key": "sk-sensitive",
  322. "api_protocol": APIProtocolAdaptive,
  323. "base_url": "https://legacy-relay.example/v1",
  324. "api_base_urls": map[string]any{
  325. APIProtocolChatCompletions: "https://chat-relay.example/v1",
  326. },
  327. },
  328. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  329. }
  330. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  331. upstream := &httpUpstreamRecorder{resp: &http.Response{
  332. StatusCode: http.StatusOK,
  333. Header: http.Header{"Content-Type": []string{"application/json"}},
  334. Body: upstreamBillingProbeValidBody(),
  335. }}
  336. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  337. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  338. require.NoError(t, err)
  339. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  340. require.Equal(t, "https://chat-relay.example/v1/sub2api/billing", upstream.lastReq.URL.String())
  341. }
  342. func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) {
  343. for _, platform := range []string{
  344. PlatformOpenAI,
  345. PlatformAnthropic,
  346. PlatformGemini,
  347. PlatformAntigravity,
  348. PlatformGrok,
  349. } {
  350. t.Run(platform, func(t *testing.T) {
  351. initialRate := 0.25
  352. account := &Account{
  353. ID: 17,
  354. Platform: platform,
  355. Type: AccountTypeAPIKey,
  356. Status: StatusActive,
  357. Concurrency: 1,
  358. RateMultiplier: &initialRate,
  359. Credentials: map[string]any{
  360. "api_key": "sk-sensitive",
  361. "base_url": "https://upstream.example",
  362. },
  363. Extra: map[string]any{
  364. UpstreamBillingProbeEnabledExtraKey: true,
  365. UpstreamBillingRateSyncEnabledExtraKey: true,
  366. },
  367. }
  368. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  369. svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{})
  370. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  371. require.NoError(t, err)
  372. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  373. require.NotNil(t, account.RateMultiplier)
  374. require.Equal(t, 0.8, *account.RateMultiplier)
  375. })
  376. }
  377. }
  378. func TestUpstreamBillingProbeOnlyDoesNotChangeAccountRate(t *testing.T) {
  379. initialRate := 0.25
  380. account := &Account{
  381. ID: 18,
  382. Platform: PlatformGrok,
  383. Type: AccountTypeAPIKey,
  384. Status: StatusActive,
  385. Concurrency: 1,
  386. RateMultiplier: &initialRate,
  387. Credentials: map[string]any{
  388. "api_key": "sk-sensitive",
  389. "base_url": "https://upstream.example",
  390. },
  391. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  392. }
  393. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  394. svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{})
  395. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  396. require.NoError(t, err)
  397. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  398. require.NotNil(t, account.RateMultiplier)
  399. require.Equal(t, initialRate, *account.RateMultiplier)
  400. require.Contains(t, account.Extra, UpstreamBillingProbeExtraKey)
  401. }
  402. func TestUpstreamBillingProbeSyncRateRangeAndPrecision(t *testing.T) {
  403. tests := []struct {
  404. name string
  405. value float64
  406. want float64
  407. ok bool
  408. }{
  409. {name: "round to four decimals", value: 0.07654, want: 0.0765, ok: true},
  410. {name: "maximum", value: upstreamBillingRateSyncMaxMultiplier, want: upstreamBillingRateSyncMaxMultiplier, ok: true},
  411. // 0 会让 accountCost 恒为 0,账号配额与成本告警全部静默失效,
  412. // 自动写回一律拒绝(管理员手工设 0 仍然允许)。
  413. {name: "zero is rejected", value: 0, ok: false},
  414. {name: "positive below database precision rounds to zero", value: 0.00001, ok: false},
  415. {name: "just above the write-back ceiling", value: 100.0001, ok: false},
  416. {name: "column ceiling is far above the write-back ceiling", value: 999999.9999, ok: false},
  417. {name: "negative", value: -1, ok: false},
  418. }
  419. for _, tt := range tests {
  420. t.Run(tt.name, func(t *testing.T) {
  421. got, ok := upstreamBillingProbeSyncRate(map[string]any{"resolved_rate_multiplier": tt.value})
  422. require.Equal(t, tt.ok, ok)
  423. if tt.ok {
  424. require.Equal(t, tt.want, got)
  425. }
  426. })
  427. }
  428. }
  429. // 只读取 resolved(时间无关的基准倍率):effective 含探测那一刻的高峰系数,
  430. // 写回它会把一个探测周期的峰值/谷值冻结进静态列。
  431. func TestUpstreamBillingProbeSyncRateIgnoresEffectiveRate(t *testing.T) {
  432. got, ok := upstreamBillingProbeSyncRate(map[string]any{
  433. "resolved_rate_multiplier": 0.6,
  434. "effective_rate_multiplier": 0.9,
  435. })
  436. require.True(t, ok)
  437. require.Equal(t, 0.6, got)
  438. _, ok = upstreamBillingProbeSyncRate(map[string]any{"effective_rate_multiplier": 0.9})
  439. require.False(t, ok)
  440. }
  441. // 上游声明超出自动写回值域时保持原倍率,但探测本身是成功的:
  442. // 快照照常记 ok,不累计 failure_count、不进入退避。
  443. func TestUpstreamBillingProbeKeepsRateWhenDeclarationOutOfSyncRange(t *testing.T) {
  444. for _, tt := range []struct {
  445. name string
  446. declared string
  447. }{
  448. {name: "zero", declared: "0"},
  449. {name: "above ceiling", declared: "1000"},
  450. } {
  451. t.Run(tt.name, func(t *testing.T) {
  452. initialRate := 0.25
  453. account := &Account{
  454. ID: 21,
  455. Platform: PlatformOpenAI,
  456. Type: AccountTypeAPIKey,
  457. Status: StatusActive,
  458. Concurrency: 1,
  459. RateMultiplier: &initialRate,
  460. Credentials: map[string]any{
  461. "api_key": "sk-sensitive",
  462. "base_url": "https://upstream.example",
  463. },
  464. Extra: map[string]any{
  465. UpstreamBillingProbeEnabledExtraKey: true,
  466. UpstreamBillingRateSyncEnabledExtraKey: true,
  467. },
  468. }
  469. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  470. upstream := &httpUpstreamRecorder{resp: &http.Response{
  471. StatusCode: http.StatusOK,
  472. Header: http.Header{"Content-Type": []string{"application/json"}},
  473. Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{
  474. "object":"sub2api.key_billing",
  475. "schema_version":1,
  476. "billing_scope":"token",
  477. "group_rate_multiplier":%[1]s,
  478. "resolved_rate_multiplier":%[1]s,
  479. "peak_rate_enabled":false,
  480. "effective_rate_multiplier":%[1]s,
  481. "observed_at":"2026-07-13T01:00:00Z"
  482. }`, tt.declared))),
  483. }}
  484. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  485. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  486. require.NoError(t, err)
  487. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  488. require.Zero(t, snapshot.FailureCount)
  489. require.Nil(t, snapshot.SyncedRateMultiplier)
  490. require.NotNil(t, account.RateMultiplier)
  491. require.Equal(t, initialRate, *account.RateMultiplier)
  492. // 原始声明仍进快照供展示。
  493. require.Equal(t, snapshot.Data["resolved_rate_multiplier"], snapshot.Data["effective_rate_multiplier"])
  494. })
  495. }
  496. }
  497. // 未开启同步的账号只观察上游声明:声明值不适配 accounts.rate_multiplier
  498. // 不得被记成探测失败(否则会累计 failure_count 并进入指数退避)。
  499. func TestUpstreamBillingProbeWithoutSyncIgnoresUnusableDeclaredRate(t *testing.T) {
  500. account := &Account{
  501. ID: 22,
  502. Platform: PlatformOpenAI,
  503. Type: AccountTypeAPIKey,
  504. Status: StatusActive,
  505. Concurrency: 1,
  506. Credentials: map[string]any{
  507. "api_key": "sk-sensitive",
  508. "base_url": "https://upstream.example",
  509. },
  510. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  511. }
  512. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  513. upstream := &httpUpstreamRecorder{resp: &http.Response{
  514. StatusCode: http.StatusOK,
  515. Header: http.Header{"Content-Type": []string{"application/json"}},
  516. Body: io.NopCloser(strings.NewReader(`{
  517. "object":"sub2api.key_billing",
  518. "schema_version":1,
  519. "billing_scope":"token",
  520. "group_rate_multiplier":0,
  521. "resolved_rate_multiplier":0,
  522. "peak_rate_enabled":false,
  523. "effective_rate_multiplier":0,
  524. "observed_at":"2026-07-13T01:00:00Z"
  525. }`)),
  526. }}
  527. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  528. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  529. require.NoError(t, err)
  530. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  531. require.Zero(t, snapshot.FailureCount)
  532. require.Empty(t, snapshot.LastError)
  533. require.Nil(t, snapshot.SyncedRateMultiplier)
  534. require.Equal(t, float64(0), snapshot.Data["resolved_rate_multiplier"])
  535. require.Nil(t, account.RateMultiplier)
  536. }
  537. func TestUpstreamBillingProbeRejectsMissingRequiredMultiplier(t *testing.T) {
  538. _, err := parseUpstreamBillingProbeResponse([]byte(`{
  539. "object":"sub2api.key_billing",
  540. "schema_version":1,
  541. "billing_scope":"token",
  542. "group_rate_multiplier":0.8,
  543. "peak_rate_enabled":false,
  544. "effective_rate_multiplier":0.8,
  545. "observed_at":"2026-07-13T01:00:00Z"
  546. }`))
  547. require.ErrorContains(t, err, "incomplete billing response")
  548. }
  549. func TestUpstreamBillingProbeDiscardsResultWhenIdentityChangesInFlight(t *testing.T) {
  550. account := &Account{
  551. ID: 19,
  552. Platform: PlatformOpenAI,
  553. Type: AccountTypeAPIKey,
  554. Status: StatusActive,
  555. Concurrency: 1,
  556. Credentials: map[string]any{"api_key": "sk-old", "base_url": "https://upstream.example"},
  557. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  558. }
  559. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  560. upstream := &upstreamBillingProbeHTTPStub{beforeResponse: func() {
  561. repo.mu.Lock()
  562. defer repo.mu.Unlock()
  563. repo.accounts[account.ID].Credentials = map[string]any{"api_key": "sk-new", "base_url": "https://new.example"}
  564. }}
  565. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  566. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  567. require.Nil(t, snapshot)
  568. require.ErrorIs(t, err, ErrUpstreamBillingProbeIdentityChanged)
  569. require.NotContains(t, repo.accounts[account.ID].Extra, UpstreamBillingProbeExtraKey)
  570. }
  571. func TestUpstreamBillingProbeRejectsInvalidPeakConfiguration(t *testing.T) {
  572. tests := []struct {
  573. name string
  574. start string
  575. end string
  576. timezone string
  577. }{
  578. {name: "invalid start", start: "25:00", end: "18:00", timezone: "UTC"},
  579. {name: "cross midnight", start: "22:00", end: "02:00", timezone: "UTC"},
  580. {name: "invalid timezone", start: "09:00", end: "18:00", timezone: "Mars/Olympus"},
  581. }
  582. for _, tt := range tests {
  583. t.Run(tt.name, func(t *testing.T) {
  584. body := fmt.Sprintf(`{
  585. "object":"sub2api.key_billing",
  586. "schema_version":1,
  587. "billing_scope":"token",
  588. "group_rate_multiplier":0.8,
  589. "resolved_rate_multiplier":0.8,
  590. "peak_rate_enabled":true,
  591. "peak_start":%q,
  592. "peak_end":%q,
  593. "peak_rate_multiplier":1.5,
  594. "applied_peak_multiplier":1,
  595. "effective_rate_multiplier":0.8,
  596. "timezone":%q,
  597. "observed_at":"2026-07-13T01:00:00Z"
  598. }`, tt.start, tt.end, tt.timezone)
  599. _, err := parseUpstreamBillingProbeResponse([]byte(body))
  600. require.ErrorContains(t, err, "invalid peak billing response")
  601. })
  602. }
  603. }
  604. func TestUpstreamBillingProbeRejectsInconsistentMultipliers(t *testing.T) {
  605. tests := []struct {
  606. name string
  607. body string
  608. }{
  609. {
  610. name: "resolved does not use user override",
  611. body: `{
  612. "object":"sub2api.key_billing","schema_version":1,"billing_scope":"token",
  613. "group_rate_multiplier":0.8,"user_rate_multiplier":0.5,"resolved_rate_multiplier":0.8,
  614. "peak_rate_enabled":false,"effective_rate_multiplier":0.8,"observed_at":"2026-07-13T01:00:00Z"
  615. }`,
  616. },
  617. {
  618. name: "effective rate does not match resolved rate",
  619. body: `{
  620. "object":"sub2api.key_billing","schema_version":1,"billing_scope":"token",
  621. "group_rate_multiplier":0.8,"resolved_rate_multiplier":0.8,
  622. "peak_rate_enabled":false,"effective_rate_multiplier":1.2,"observed_at":"2026-07-13T01:00:00Z"
  623. }`,
  624. },
  625. {
  626. name: "applied peak does not match observed window",
  627. body: `{
  628. "object":"sub2api.key_billing","schema_version":1,"billing_scope":"token",
  629. "group_rate_multiplier":0.8,"resolved_rate_multiplier":0.8,
  630. "peak_rate_enabled":true,"peak_start":"09:00","peak_end":"18:00",
  631. "peak_rate_multiplier":1.5,"applied_peak_multiplier":1,
  632. "effective_rate_multiplier":0.8,"timezone":"Asia/Shanghai","observed_at":"2026-07-13T01:00:00Z"
  633. }`,
  634. },
  635. }
  636. for _, tt := range tests {
  637. t.Run(tt.name, func(t *testing.T) {
  638. _, err := parseUpstreamBillingProbeResponse([]byte(tt.body))
  639. require.ErrorContains(t, err, "inconsistent")
  640. })
  641. }
  642. }
  643. func TestUpstreamBillingRateAtHandlesDST(t *testing.T) {
  644. data := map[string]any{
  645. "billing_scope": "token",
  646. "resolved_rate_multiplier": 1.0,
  647. "peak_rate_enabled": true,
  648. "peak_start": "02:00",
  649. "peak_end": "04:00",
  650. "peak_rate_multiplier": 2.0,
  651. "timezone": "America/New_York",
  652. }
  653. beforeJump := time.Date(2026, time.March, 8, 6, 30, 0, 0, time.UTC)
  654. afterJump := time.Date(2026, time.March, 8, 7, 30, 0, 0, time.UTC)
  655. rate, ok := upstreamBillingRateAt(data, beforeJump)
  656. require.True(t, ok)
  657. require.Equal(t, 1.0, rate)
  658. rate, ok = upstreamBillingRateAt(data, afterJump)
  659. require.True(t, ok)
  660. require.Equal(t, 2.0, rate)
  661. }
  662. func TestUpstreamBillingProbeFailurePreservesLastSuccessAndRetryAfter(t *testing.T) {
  663. receivedAt := time.Date(2026, time.July, 12, 12, 0, 0, 0, time.UTC)
  664. initialRate := 0.35
  665. previous := &UpstreamBillingProbeSnapshot{
  666. Status: UpstreamBillingProbeStatusOK,
  667. Data: map[string]any{"effective_rate_multiplier": 0.5},
  668. ReceivedAt: &receivedAt,
  669. FailureCount: 1,
  670. }
  671. account := &Account{
  672. ID: 18,
  673. Platform: PlatformOpenAI,
  674. Type: AccountTypeAPIKey,
  675. Status: StatusActive,
  676. Concurrency: 1,
  677. RateMultiplier: &initialRate,
  678. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  679. Extra: map[string]any{
  680. UpstreamBillingProbeEnabledExtraKey: true,
  681. UpstreamBillingProbeExtraKey: previous,
  682. },
  683. }
  684. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  685. upstream := &httpUpstreamRecorder{resp: &http.Response{
  686. StatusCode: http.StatusTooManyRequests,
  687. Header: http.Header{"Retry-After": []string{"14400"}},
  688. Body: io.NopCloser(strings.NewReader(`{"error":"do not persist this"}`)),
  689. }}
  690. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  691. fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
  692. svc.now = func() time.Time { return fixedNow }
  693. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  694. require.NoError(t, err)
  695. require.Equal(t, UpstreamBillingProbeStatusFailed, snapshot.Status)
  696. require.Equal(t, previous.Data, snapshot.Data)
  697. require.Equal(t, previous.ReceivedAt, snapshot.ReceivedAt)
  698. require.NotNil(t, snapshot.FreshUntil)
  699. require.Equal(t, receivedAt.Add(time.Hour), *snapshot.FreshUntil)
  700. require.Equal(t, 2, snapshot.FailureCount)
  701. require.Equal(t, "http_error", snapshot.LastError)
  702. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(4*time.Hour)))
  703. require.NotContains(t, snapshot.LastError, "do not persist")
  704. require.NotNil(t, account.RateMultiplier)
  705. require.Equal(t, initialRate, *account.RateMultiplier)
  706. }
  707. func TestUpstreamBillingProbeRetryAfterIsNotShortened(t *testing.T) {
  708. delay := nextProbeDelay(30, 48*time.Hour)
  709. require.Equal(t, 48*time.Hour, delay)
  710. }
  711. // unsupported 的重探间隔明显长于普通失败,但始终有上界:上游后来接入 sub2api
  712. // 时最迟一天内会被重新发现,且不会缩短上游 Retry-After 指令。
  713. func TestUpstreamBillingProbeUnsupportedDelayIsStretchedAndBounded(t *testing.T) {
  714. // 默认 30 分钟 interval:普通失败 24~36 分钟,unsupported 为其 8 倍。
  715. stretched := unsupportedProbeDelay(30, 0)
  716. require.Greater(t, stretched, 36*time.Minute)
  717. require.GreaterOrEqual(t, stretched, 192*time.Minute)
  718. require.LessOrEqual(t, stretched, 288*time.Minute)
  719. // 永不超过封顶值,因此 unsupported 账号不会被永久排除在重探之外。
  720. require.LessOrEqual(t, unsupportedProbeDelay(upstreamBillingProbeMaxIntervalMinutes, 0), upstreamBillingProbeMaxDelay)
  721. require.Positive(t, unsupportedProbeDelay(upstreamBillingProbeMinIntervalMinutes, 0))
  722. // Retry-After 更长时原样保留,不被封顶缩短;更短时至少不早于该指令。
  723. require.Equal(t, 48*time.Hour, unsupportedProbeDelay(30, 48*time.Hour))
  724. require.GreaterOrEqual(t, unsupportedProbeDelay(30, time.Hour), time.Hour)
  725. }
  726. // 加长退避只把 unsupported 账号移出周期性热队列,手动探测不受影响。
  727. func TestUpstreamBillingProbeUnsupportedBackoffDefersRunnerButNotManualProbe(t *testing.T) {
  728. // Ollama Cloud 形态:官方域,不发请求直接落 unsupported。
  729. account := &Account{
  730. ID: 31,
  731. Platform: PlatformAnthropic,
  732. Type: AccountTypeAPIKey,
  733. Status: StatusActive,
  734. Concurrency: 1,
  735. Credentials: map[string]any{"api_key": "sk-ollama", "base_url": "https://ollama.com/v1"},
  736. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  737. }
  738. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  739. upstream := &upstreamBillingProbeHTTPStub{}
  740. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  741. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  742. }}
  743. svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  744. start := time.Date(2026, time.July, 26, 2, 0, 0, 0, time.UTC)
  745. now := start
  746. svc.now = func() time.Time { return now }
  747. require.NoError(t, svc.RunDue(context.Background()))
  748. first := decodeUpstreamBillingProbeSnapshot(account.Extra)
  749. require.NotNil(t, first)
  750. require.Equal(t, UpstreamBillingProbeStatusUnsupported, first.Status)
  751. require.Equal(t, start, first.LastAttemptAt)
  752. require.False(t, first.NextProbeAt.Before(start.Add(192*time.Minute)))
  753. require.Zero(t, upstream.calls.Load())
  754. // 一个普通失败早就该重探的时间点(远超 36 分钟),runner 仍跳过该账号。
  755. now = start.Add(90 * time.Minute)
  756. require.NoError(t, svc.RunDue(context.Background()))
  757. deferred := decodeUpstreamBillingProbeSnapshot(account.Extra)
  758. require.NotNil(t, deferred)
  759. require.Equal(t, first.LastAttemptAt, deferred.LastAttemptAt)
  760. // 手动探测无视退避窗口,管理员随时可以重试。
  761. manual, err := svc.ProbeAccount(context.Background(), account.ID)
  762. require.NoError(t, err)
  763. require.Equal(t, UpstreamBillingProbeStatusUnsupported, manual.Status)
  764. require.Equal(t, now, manual.LastAttemptAt)
  765. require.Equal(t, 2, manual.FailureCount)
  766. }
  767. func TestUpstreamBillingProbeEmptyResponseIsPersistedAsFailure(t *testing.T) {
  768. account := &Account{
  769. ID: 21,
  770. Platform: PlatformOpenAI,
  771. Type: AccountTypeAPIKey,
  772. Status: StatusActive,
  773. Concurrency: 1,
  774. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  775. }
  776. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  777. svc := newUpstreamBillingProbeTestService(repo, &httpUpstreamRecorder{}, &upstreamBillingProbeSettingRepo{})
  778. fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
  779. svc.now = func() time.Time { return fixedNow }
  780. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  781. require.NoError(t, err)
  782. require.Equal(t, UpstreamBillingProbeStatusFailed, snapshot.Status)
  783. require.Equal(t, "empty_response", snapshot.LastError)
  784. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
  785. require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
  786. snapshot, err = svc.ProbeAccount(context.Background(), account.ID)
  787. require.NoError(t, err)
  788. require.Equal(t, 2, snapshot.FailureCount)
  789. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute)))
  790. require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute)))
  791. }
  792. func TestUpstreamBillingProbeUnsupportedAndAccountToggle(t *testing.T) {
  793. account := &Account{
  794. ID: 19,
  795. Platform: PlatformOpenAI,
  796. Type: AccountTypeAPIKey,
  797. Status: StatusActive,
  798. Concurrency: 1,
  799. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  800. }
  801. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  802. upstream := &httpUpstreamRecorder{resp: &http.Response{
  803. StatusCode: http.StatusNotFound,
  804. Header: http.Header{},
  805. Body: io.NopCloser(strings.NewReader("not found")),
  806. }}
  807. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  808. fixedNow := time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC)
  809. svc.now = func() time.Time { return fixedNow }
  810. require.NoError(t, svc.SetAccountEnabled(context.Background(), account.ID, true))
  811. require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey])
  812. account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true
  813. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  814. require.NoError(t, err)
  815. require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status)
  816. require.Equal(t, "unsupported", snapshot.LastError)
  817. // unsupported 走加长退避:默认 30 分钟 interval ⇒ (24~36) * 8 分钟。
  818. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(192*time.Minute)))
  819. require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(288*time.Minute)))
  820. snapshot, err = svc.ProbeAccount(context.Background(), account.ID)
  821. require.NoError(t, err)
  822. require.Equal(t, 2, snapshot.FailureCount)
  823. require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(192*time.Minute)))
  824. require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(288*time.Minute)))
  825. require.NoError(t, svc.SetAccountEnabled(context.Background(), account.ID, false))
  826. require.Equal(t, false, account.Extra[UpstreamBillingProbeEnabledExtraKey])
  827. require.Equal(t, false, account.Extra[UpstreamBillingRateSyncEnabledExtraKey])
  828. invalid := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  829. repo.accounts[invalid.ID] = invalid
  830. err = svc.SetAccountEnabled(context.Background(), invalid.ID, true)
  831. require.True(t, errors.Is(err, ErrUpstreamBillingProbeAccountInvalid))
  832. }
  833. func TestUpstreamBillingProbeRunnerIsBoundedAndManualProbeIgnoresSwitches(t *testing.T) {
  834. accounts := make(map[int64]*Account, 25)
  835. for id := int64(1); id <= 25; id++ {
  836. accounts[id] = &Account{
  837. ID: id,
  838. Platform: PlatformOpenAI,
  839. Type: AccountTypeAPIKey,
  840. Status: StatusActive,
  841. Concurrency: 1,
  842. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  843. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  844. }
  845. }
  846. repo := &upstreamBillingProbeAccountRepo{accounts: accounts}
  847. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  848. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  849. }}
  850. upstream := &upstreamBillingProbeHTTPStub{}
  851. svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  852. svc.now = func() time.Time { return time.Date(2026, time.July, 13, 2, 0, 0, 0, time.UTC) }
  853. require.NoError(t, svc.RunDue(context.Background()))
  854. require.Equal(t, int64(20), upstream.calls.Load())
  855. settingsRepo.mu.Lock()
  856. settingsRepo.values[SettingKeyUpstreamBillingProbeSettings] = `{"enabled":false,"interval_minutes":30}`
  857. settingsRepo.mu.Unlock()
  858. require.NoError(t, svc.RunDue(context.Background()))
  859. require.Equal(t, int64(20), upstream.calls.Load())
  860. accounts[25].Extra[UpstreamBillingProbeEnabledExtraKey] = false
  861. manualRate := 0.25
  862. accounts[25].RateMultiplier = &manualRate
  863. snapshot, err := svc.ProbeAccount(context.Background(), 25)
  864. require.NoError(t, err)
  865. require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
  866. require.Equal(t, int64(21), upstream.calls.Load())
  867. require.NotNil(t, accounts[25].RateMultiplier)
  868. require.Equal(t, manualRate, *accounts[25].RateMultiplier)
  869. }
  870. func TestUpstreamBillingProbeRunnerRechecksEnabledAfterDueSelection(t *testing.T) {
  871. account := &Account{
  872. ID: 26,
  873. Platform: PlatformOpenAI,
  874. Type: AccountTypeAPIKey,
  875. Status: StatusActive,
  876. Concurrency: 1,
  877. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  878. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: false},
  879. }
  880. staleDue := *account
  881. staleDue.Extra = map[string]any{UpstreamBillingProbeEnabledExtraKey: true}
  882. baseRepo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  883. repo := &staleDueUpstreamBillingProbeAccountRepo{upstreamBillingProbeAccountRepo: baseRepo, due: []Account{staleDue}}
  884. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  885. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  886. }}
  887. upstream := &upstreamBillingProbeHTTPStub{}
  888. svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  889. require.NoError(t, svc.RunDue(context.Background()))
  890. require.Zero(t, upstream.calls.Load())
  891. require.NotContains(t, account.Extra, UpstreamBillingProbeExtraKey)
  892. }
  893. func TestUpstreamBillingProbeNeverDowngradesMissingConfiguredProxyToDirect(t *testing.T) {
  894. proxyID := int64(7)
  895. for _, tc := range []struct {
  896. name string
  897. proxy *Proxy
  898. wantReason string
  899. wantErr error
  900. }{
  901. {name: "missing hydrated proxy", wantReason: "proxy_unavailable"},
  902. {name: "mismatched hydrated proxy", proxy: &Proxy{ID: 8, Protocol: "http", Host: "127.0.0.1", Port: 8080}, wantErr: ErrUpstreamBillingProbeIdentityChanged},
  903. } {
  904. t.Run(tc.name, func(t *testing.T) {
  905. account := &Account{
  906. ID: 27,
  907. Platform: PlatformOpenAI,
  908. Type: AccountTypeAPIKey,
  909. Status: StatusActive,
  910. Concurrency: 1,
  911. Credentials: map[string]any{"api_key": "sk-sensitive", "base_url": "https://upstream.example"},
  912. ProxyID: &proxyID,
  913. Proxy: tc.proxy,
  914. }
  915. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  916. upstream := &upstreamBillingProbeHTTPStub{}
  917. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  918. snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
  919. if tc.wantErr != nil {
  920. require.ErrorIs(t, err, tc.wantErr)
  921. require.Nil(t, snapshot)
  922. } else {
  923. require.NoError(t, err)
  924. require.Equal(t, UpstreamBillingProbeStatusFailed, snapshot.Status)
  925. require.Equal(t, tc.wantReason, snapshot.LastError)
  926. }
  927. require.Zero(t, upstream.calls.Load())
  928. if tc.wantErr != nil {
  929. require.NotContains(t, account.Extra, UpstreamBillingProbeExtraKey)
  930. }
  931. })
  932. }
  933. }
  934. func TestUpstreamBillingProbeRunnerOnlyScansOnLeader(t *testing.T) {
  935. account := &Account{
  936. ID: 31,
  937. Platform: PlatformOpenAI,
  938. Type: AccountTypeAPIKey,
  939. Status: StatusActive,
  940. Concurrency: 1,
  941. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  942. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  943. }
  944. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  945. upstream := &upstreamBillingProbeHTTPStub{}
  946. cache := &fakeLeaderLockCache{}
  947. lockKey := upstreamBillingProbeLeaderLockKeyAt(time.Now())
  948. peer := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  949. peer.instanceID = "peer"
  950. peer.SetLeaderLock(cache, nil)
  951. _, acquired, err := peer.tryAcquireLeaderLock(context.Background(), lockKey)
  952. require.NoError(t, err)
  953. require.True(t, acquired)
  954. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  955. svc.SetLeaderLock(cache, nil)
  956. require.NoError(t, svc.RunDue(context.Background()))
  957. require.Zero(t, upstream.calls.Load())
  958. require.NoError(t, cache.ReleaseLeaderLock(context.Background(), lockKey, "peer"))
  959. require.NoError(t, svc.RunDue(context.Background()))
  960. require.Equal(t, int64(1), upstream.calls.Load())
  961. }
  962. func TestUpstreamBillingProbeLeaderLockFailsClosedOnCacheError(t *testing.T) {
  963. svc := newUpstreamBillingProbeTestService(&upstreamBillingProbeAccountRepo{}, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{})
  964. svc.SetLeaderLock(&fakeLeaderLockCache{acquireErr: context.DeadlineExceeded}, nil)
  965. release, acquired, err := svc.tryAcquireLeaderLock(context.Background(), upstreamBillingProbeLeaderLockKeyAt(time.Now()))
  966. require.ErrorIs(t, err, context.DeadlineExceeded)
  967. require.False(t, acquired)
  968. require.Nil(t, release)
  969. }
  970. func TestUpstreamBillingProbeLeaderLockUsesCadenceBuckets(t *testing.T) {
  971. cache := &fakeLeaderLockCache{}
  972. first := newUpstreamBillingProbeTestService(&upstreamBillingProbeAccountRepo{}, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{})
  973. second := newUpstreamBillingProbeTestService(&upstreamBillingProbeAccountRepo{}, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{})
  974. first.SetLeaderLock(cache, nil)
  975. second.SetLeaderLock(cache, nil)
  976. beforeBoundary := time.Unix(59, 0)
  977. afterBoundary := beforeBoundary.Add(time.Second)
  978. releaseFirst, acquired, err := first.tryAcquireLeaderLock(context.Background(), upstreamBillingProbeLeaderLockKeyAt(beforeBoundary))
  979. require.NoError(t, err)
  980. require.True(t, acquired)
  981. releaseSecond, acquired, err := second.tryAcquireLeaderLock(context.Background(), upstreamBillingProbeLeaderLockKeyAt(afterBoundary))
  982. require.NoError(t, err)
  983. require.True(t, acquired, "the prior cadence lock must not suppress the next cadence")
  984. releaseFirst()
  985. releaseSecond()
  986. }
  987. func TestUpstreamBillingProbeFiveInstancesRunOneConcurrentBatch(t *testing.T) {
  988. account := &Account{
  989. ID: 32,
  990. Platform: PlatformOpenAI,
  991. Type: AccountTypeAPIKey,
  992. Status: StatusActive,
  993. Concurrency: 1,
  994. Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://127.0.0.1:8080"},
  995. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  996. }
  997. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  998. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  999. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  1000. }}
  1001. cache := &fakeLeaderLockCache{}
  1002. entered := make(chan struct{})
  1003. unblock := make(chan struct{})
  1004. var enteredOnce sync.Once
  1005. upstream := &upstreamBillingProbeHTTPStub{beforeResponse: func() {
  1006. enteredOnce.Do(func() { close(entered) })
  1007. <-unblock
  1008. }}
  1009. start := make(chan struct{})
  1010. results := make(chan error, 5)
  1011. for range 5 {
  1012. svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  1013. svc.SetLeaderLock(cache, nil)
  1014. go func() {
  1015. <-start
  1016. results <- svc.RunDue(context.Background())
  1017. }()
  1018. }
  1019. close(start)
  1020. select {
  1021. case <-entered:
  1022. case <-time.After(time.Second):
  1023. t.Fatal("leader did not start the probe batch")
  1024. }
  1025. for range 4 {
  1026. select {
  1027. case err := <-results:
  1028. require.NoError(t, err)
  1029. case <-time.After(time.Second):
  1030. t.Fatal("non-leader instance did not skip the active batch")
  1031. }
  1032. }
  1033. require.Equal(t, int64(1), upstream.calls.Load())
  1034. close(unblock)
  1035. require.NoError(t, <-results)
  1036. require.Equal(t, int64(1), upstream.calls.Load())
  1037. }
  1038. func TestUpstreamBillingProbeManualBatchesShareConcurrencyLimit(t *testing.T) {
  1039. accounts := make(map[int64]*Account, 12)
  1040. for id := int64(1); id <= 12; id++ {
  1041. accounts[id] = &Account{
  1042. ID: id,
  1043. Platform: PlatformOpenAI,
  1044. Type: AccountTypeAPIKey,
  1045. Status: StatusActive,
  1046. Concurrency: 1,
  1047. Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://127.0.0.1:8080"},
  1048. }
  1049. }
  1050. repo := &upstreamBillingProbeAccountRepo{accounts: accounts}
  1051. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  1052. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  1053. }}
  1054. entered := make(chan struct{}, len(accounts))
  1055. unblock := make(chan struct{})
  1056. var unblockOnce sync.Once
  1057. release := func() { unblockOnce.Do(func() { close(unblock) }) }
  1058. t.Cleanup(release)
  1059. upstream := &upstreamBillingProbeHTTPStub{beforeResponse: func() {
  1060. entered <- struct{}{}
  1061. <-unblock
  1062. }}
  1063. svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  1064. results := make(chan []UpstreamBillingProbeResult, 3)
  1065. for batch := 0; batch < 3; batch++ {
  1066. firstID := int64(batch*4 + 1)
  1067. ids := []int64{firstID, firstID + 1, firstID + 2, firstID + 3}
  1068. go func() { results <- svc.ProbeAccounts(context.Background(), ids) }()
  1069. }
  1070. for range upstreamBillingProbeConcurrency {
  1071. select {
  1072. case <-entered:
  1073. case <-time.After(time.Second):
  1074. t.Fatal("shared probe slots did not fill")
  1075. }
  1076. }
  1077. select {
  1078. case <-entered:
  1079. release()
  1080. t.Fatal("parallel manual batches exceeded the service-wide concurrency limit")
  1081. case <-time.After(100 * time.Millisecond):
  1082. }
  1083. release()
  1084. for range 3 {
  1085. select {
  1086. case batchResults := <-results:
  1087. for _, result := range batchResults {
  1088. require.Empty(t, result.Error)
  1089. require.NotNil(t, result.Snapshot)
  1090. }
  1091. case <-time.After(time.Second):
  1092. t.Fatal("manual probe batch did not finish")
  1093. }
  1094. }
  1095. require.Equal(t, int64(upstreamBillingProbeConcurrency), upstream.maxActive.Load())
  1096. }
  1097. func TestUpstreamBillingProbeManualAndScheduledRequestsShareOneNetworkProbe(t *testing.T) {
  1098. account := &Account{
  1099. ID: 46,
  1100. Platform: PlatformOpenAI,
  1101. Type: AccountTypeAPIKey,
  1102. Status: StatusActive,
  1103. Concurrency: 1,
  1104. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  1105. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  1106. }
  1107. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  1108. started := make(chan struct{})
  1109. unblock := make(chan struct{})
  1110. var startedOnce sync.Once
  1111. upstream := &upstreamBillingProbeHTTPStub{beforeResponse: func() {
  1112. startedOnce.Do(func() { close(started) })
  1113. <-unblock
  1114. }}
  1115. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  1116. errs := make(chan error, 2)
  1117. go func() {
  1118. _, err := svc.probeScheduledAccount(context.Background(), account.ID, 30)
  1119. errs <- err
  1120. }()
  1121. select {
  1122. case <-started:
  1123. case <-time.After(time.Second):
  1124. t.Fatal("scheduled probe did not reach the upstream")
  1125. }
  1126. manualStarted := make(chan struct{})
  1127. go func() {
  1128. close(manualStarted)
  1129. _, err := svc.ProbeAccount(context.Background(), account.ID)
  1130. errs <- err
  1131. }()
  1132. <-manualStarted
  1133. time.Sleep(20 * time.Millisecond)
  1134. close(unblock)
  1135. require.NoError(t, <-errs)
  1136. require.NoError(t, <-errs)
  1137. require.Equal(t, int64(1), upstream.calls.Load())
  1138. }
  1139. func TestUpstreamBillingProbeScheduledRechecksAfterWaitingForSlot(t *testing.T) {
  1140. account := &Account{
  1141. ID: 47,
  1142. Platform: PlatformOpenAI,
  1143. Type: AccountTypeAPIKey,
  1144. Status: StatusActive,
  1145. Concurrency: 1,
  1146. Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"},
  1147. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  1148. }
  1149. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  1150. upstream := &upstreamBillingProbeHTTPStub{}
  1151. svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
  1152. for range upstreamBillingProbeConcurrency {
  1153. svc.probeSlots <- struct{}{}
  1154. }
  1155. result := make(chan error, 1)
  1156. go func() {
  1157. _, err := svc.probeScheduledAccount(context.Background(), account.ID, 30)
  1158. result <- err
  1159. }()
  1160. time.Sleep(20 * time.Millisecond)
  1161. repo.mu.Lock()
  1162. account.Extra[UpstreamBillingProbeEnabledExtraKey] = false
  1163. repo.mu.Unlock()
  1164. <-svc.probeSlots
  1165. require.NoError(t, <-result)
  1166. require.Zero(t, upstream.calls.Load())
  1167. }
  1168. func TestUpstreamBillingProbeLeaderLockCoversStaggeredInstancesInCadenceWindow(t *testing.T) {
  1169. account := func(id int64) *Account {
  1170. return &Account{
  1171. ID: id,
  1172. Platform: PlatformOpenAI,
  1173. Type: AccountTypeAPIKey,
  1174. Status: StatusActive,
  1175. Concurrency: 1,
  1176. Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://127.0.0.1:8080"},
  1177. Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
  1178. }
  1179. }
  1180. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{41: account(41)}}
  1181. settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
  1182. SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`,
  1183. }}
  1184. cache := &fakeLeaderLockCache{}
  1185. upstream := &upstreamBillingProbeHTTPStub{}
  1186. first := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  1187. first.SetLeaderLock(cache, nil)
  1188. require.NoError(t, first.RunDue(context.Background()))
  1189. require.Equal(t, int64(1), upstream.calls.Load())
  1190. require.Equal(t, first.instanceID, cache.heldBy(upstreamBillingProbeLeaderLockKeyAt(time.Now())))
  1191. repo.mu.Lock()
  1192. repo.accounts[42] = account(42)
  1193. repo.mu.Unlock()
  1194. staggered := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo)
  1195. staggered.SetLeaderLock(cache, nil)
  1196. require.NoError(t, staggered.RunDue(context.Background()))
  1197. require.Equal(t, int64(1), upstream.calls.Load(), "a staggered instance must not start a second batch inside the cadence window")
  1198. }