openai_account_scheduler_upstream_cost_test.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "math"
  6. "strconv"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type upstreamCostTrackingConcurrencyCache struct {
  13. ConcurrencyCache
  14. loadMap map[int64]*AccountLoadInfo
  15. acquireLimits map[int64][]int
  16. releases map[int64]int
  17. rejectAcquire bool
  18. }
  19. func (c *upstreamCostTrackingConcurrencyCache) AcquireAccountSlot(_ context.Context, accountID int64, maxConcurrency int, _ string) (bool, error) {
  20. if c.acquireLimits == nil {
  21. c.acquireLimits = make(map[int64][]int)
  22. }
  23. c.acquireLimits[accountID] = append(c.acquireLimits[accountID], maxConcurrency)
  24. return !c.rejectAcquire, nil
  25. }
  26. func (c *upstreamCostTrackingConcurrencyCache) ReleaseAccountSlot(_ context.Context, accountID int64, _ string) error {
  27. if c.releases == nil {
  28. c.releases = make(map[int64]int)
  29. }
  30. c.releases[accountID]++
  31. return nil
  32. }
  33. func (c *upstreamCostTrackingConcurrencyCache) GetAccountsLoadBatch(_ context.Context, accounts []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error) {
  34. out := make(map[int64]*AccountLoadInfo, len(accounts))
  35. for _, account := range accounts {
  36. if load := c.loadMap[account.ID]; load != nil {
  37. copied := *load
  38. out[account.ID] = &copied
  39. }
  40. }
  41. return out, nil
  42. }
  43. func (c *upstreamCostTrackingConcurrencyCache) limits(accountID int64) []int {
  44. return append([]int(nil), c.acquireLimits[accountID]...)
  45. }
  46. func (c *upstreamCostTrackingConcurrencyCache) releaseCount(accountID int64) int {
  47. return c.releases[accountID]
  48. }
  49. func (c *upstreamCostTrackingConcurrencyCache) totalAcquires() int {
  50. total := 0
  51. for _, limits := range c.acquireLimits {
  52. total += len(limits)
  53. }
  54. return total
  55. }
  56. type upstreamCostCountingAccountRepo struct {
  57. AccountRepository
  58. accounts map[int64]*Account
  59. getCalls int
  60. }
  61. func (r *upstreamCostCountingAccountRepo) GetByID(_ context.Context, accountID int64) (*Account, error) {
  62. r.getCalls++
  63. account := r.accounts[accountID]
  64. if account == nil {
  65. return nil, errors.New("account not found")
  66. }
  67. cloned := *account
  68. return &cloned, nil
  69. }
  70. func (r *upstreamCostCountingAccountRepo) calls() int {
  71. return r.getCalls
  72. }
  73. func upstreamCostTestAccount(id int64, status string, rate float64, receivedAt time.Time, interval time.Duration) *Account {
  74. return &Account{
  75. ID: id,
  76. Platform: PlatformOpenAI,
  77. Type: AccountTypeAPIKey,
  78. Extra: map[string]any{
  79. UpstreamBillingProbeExtraKey: map[string]any{
  80. "status": status,
  81. "data": map[string]any{
  82. "billing_scope": "token",
  83. "resolved_rate_multiplier": rate,
  84. "peak_rate_enabled": false,
  85. "effective_rate_multiplier": rate,
  86. },
  87. "received_at": receivedAt.UTC().Format(time.RFC3339Nano),
  88. "fresh_until": receivedAt.Add(2 * interval).UTC().Format(time.RFC3339Nano),
  89. "last_attempt_at": receivedAt.UTC().Format(time.RFC3339Nano),
  90. "next_probe_at": receivedAt.Add(interval).UTC().Format(time.RFC3339Nano),
  91. },
  92. },
  93. }
  94. }
  95. func upstreamCostTestOAuthAccount(id int64) *Account {
  96. return &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  97. }
  98. func TestAdvancedCostSchedulerUsesTopKOverflowWhenPreferredAccountIsKnownFull(t *testing.T) {
  99. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  100. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  101. now := time.Now()
  102. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute)
  103. expensive := upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  104. for _, account := range []*Account{cheap, expensive} {
  105. account.Status = StatusActive
  106. account.Schedulable = true
  107. account.Concurrency = 1
  108. }
  109. cache := &upstreamCostTrackingConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{
  110. cheap.ID: {AccountID: cheap.ID, CurrentConcurrency: 1, LoadRate: 100},
  111. expensive.ID: {AccountID: expensive.ID},
  112. }}
  113. cfg := &config.Config{}
  114. cfg.Gateway.OpenAIWS.LBTopK = 1
  115. cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 1
  116. svc := &OpenAIGatewayService{
  117. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}},
  118. cfg: cfg,
  119. rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
  120. concurrencyService: NewConcurrencyService(cache),
  121. }
  122. groupID := int64(1)
  123. selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false)
  124. require.NoError(t, err)
  125. require.Equal(t, expensive.ID, selection.Account.ID)
  126. require.Empty(t, cache.limits(cheap.ID))
  127. require.Equal(t, []int{1}, cache.limits(expensive.ID))
  128. selection.ReleaseFunc()
  129. }
  130. func TestAdvancedSchedulerCapsRejectedCostOverflowAcquires(t *testing.T) {
  131. selectionOrder := make([]openAIAccountCandidateScore, 0, 15_000)
  132. for id := int64(1); id <= 15_000; id++ {
  133. account := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  134. selectionOrder = append(selectionOrder, openAIAccountCandidateScore{
  135. account: account, loadInfo: &AccountLoadInfo{AccountID: id}, loadKnown: false,
  136. })
  137. }
  138. cache := &upstreamCostTrackingConcurrencyCache{rejectAcquire: true}
  139. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  140. concurrencyService: NewConcurrencyService(cache),
  141. }}
  142. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(
  143. context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, selectionOrder,
  144. )
  145. require.NoError(t, err)
  146. require.Nil(t, selection)
  147. require.Equal(t, openAIAccountSelectionProbeLimit, cache.totalAcquires())
  148. }
  149. func TestOpenAICostOverflowExpandedOnlyWhenCostAddsCandidates(t *testing.T) {
  150. candidates := []openAIAccountCandidateScore{
  151. {account: &Account{ID: 1, Extra: map[string]any{"openai_compact_supported": true}}},
  152. {account: &Account{ID: 2}},
  153. }
  154. plan := openAIAccountLoadPlan{candidates: candidates, topK: 1, includeOverflowFallback: true}
  155. require.True(t, openAICostOverflowExpanded(OpenAIAccountScheduleRequest{}, plan))
  156. require.False(t, openAICostOverflowExpanded(OpenAIAccountScheduleRequest{RequireCompact: true}, plan),
  157. "one candidate per compact tier does not expand either tier's top-k")
  158. plan.topK = len(candidates)
  159. require.False(t, openAICostOverflowExpanded(OpenAIAccountScheduleRequest{}, plan))
  160. plan.includeOverflowFallback = false
  161. plan.topK = 1
  162. require.False(t, openAICostOverflowExpanded(OpenAIAccountScheduleRequest{}, plan))
  163. }
  164. func TestAdvancedSchedulerKnownFullOverflowStillFindsAvailableAccount(t *testing.T) {
  165. selectionOrder := make([]openAIAccountCandidateScore, 0, openAIAccountSelectionProbeLimit+2)
  166. for id := int64(1); id <= openAIAccountSelectionProbeLimit+1; id++ {
  167. account := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  168. selectionOrder = append(selectionOrder, openAIAccountCandidateScore{
  169. account: account,
  170. loadInfo: &AccountLoadInfo{AccountID: id, CurrentConcurrency: 1, LoadRate: 100},
  171. loadKnown: true,
  172. })
  173. }
  174. availableID := int64(openAIAccountSelectionProbeLimit + 2)
  175. available := &Account{ID: availableID, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  176. selectionOrder = append(selectionOrder, openAIAccountCandidateScore{
  177. account: available, loadInfo: &AccountLoadInfo{AccountID: availableID}, loadKnown: true,
  178. })
  179. cache := &upstreamCostTrackingConcurrencyCache{}
  180. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  181. concurrencyService: NewConcurrencyService(cache),
  182. }}
  183. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(
  184. context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, selectionOrder,
  185. )
  186. require.NoError(t, err)
  187. require.NotNil(t, selection)
  188. require.Equal(t, availableID, selection.Account.ID)
  189. require.Equal(t, 1, cache.totalAcquires())
  190. selection.ReleaseFunc()
  191. }
  192. func TestAdvancedSchedulerSharesProbeBudgetWithFallbackDBRechecks(t *testing.T) {
  193. const size = 15_000
  194. latestAccounts := make(map[int64]*Account, size)
  195. snapshotAccounts := make(map[int64]*Account, size)
  196. selectionOrder := make([]openAIAccountCandidateScore, 0, size)
  197. for id := int64(1); id <= size; id++ {
  198. stale := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  199. latest := *stale
  200. latest.Status = StatusDisabled
  201. snapshotAccounts[id] = stale
  202. latestAccounts[id] = &latest
  203. selectionOrder = append(selectionOrder, openAIAccountCandidateScore{
  204. account: stale, loadInfo: &AccountLoadInfo{AccountID: id}, loadKnown: false,
  205. })
  206. }
  207. repo := &upstreamCostCountingAccountRepo{accounts: latestAccounts}
  208. cache := &upstreamCostTrackingConcurrencyCache{}
  209. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  210. accountRepo: repo,
  211. schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{accountsByID: snapshotAccounts}},
  212. concurrencyService: NewConcurrencyService(cache),
  213. }}
  214. budget := newOpenAISelectionProbeBudget()
  215. budget.enableLimit()
  216. req := OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}
  217. selection, _, err := scheduler.tryAcquireOpenAISelectionOrderWithBudget(context.Background(), req, selectionOrder, budget)
  218. require.NoError(t, err)
  219. require.Nil(t, selection)
  220. selection, _, _, _, err = scheduler.finishLoadBalanceSelectionFallback(
  221. context.Background(), req, openAIAccountLoadSelectionAttempt{selectionOrder: selectionOrder}, budget, openAISelectionFilterStats{},
  222. )
  223. require.Error(t, err)
  224. require.Nil(t, selection)
  225. require.Equal(t, openAIAccountSelectionProbeLimit, cache.totalAcquires())
  226. require.Equal(t, openAIAccountSelectionProbeLimit, repo.calls())
  227. }
  228. func TestAdvancedCostSchedulerKeepsCompactSupportedOverflowAheadOfUnknown(t *testing.T) {
  229. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  230. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  231. now := time.Now()
  232. preferred := upstreamCostTestAccount(11, UpstreamBillingProbeStatusOK, 0.01, now.Add(-time.Minute), 30*time.Minute)
  233. overflow := upstreamCostTestAccount(12, UpstreamBillingProbeStatusOK, 0.1, now.Add(-time.Minute), 30*time.Minute)
  234. unknown := upstreamCostTestAccount(13, UpstreamBillingProbeStatusOK, 0.001, now.Add(-time.Minute), 30*time.Minute)
  235. preferred.Extra["openai_compact_supported"] = true
  236. overflow.Extra["openai_compact_supported"] = true
  237. for _, account := range []*Account{preferred, overflow, unknown} {
  238. account.Status = StatusActive
  239. account.Schedulable = true
  240. account.Concurrency = 1
  241. }
  242. cache := &upstreamCostTrackingConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{
  243. preferred.ID: {AccountID: preferred.ID, CurrentConcurrency: 1, LoadRate: 100},
  244. overflow.ID: {AccountID: overflow.ID},
  245. unknown.ID: {AccountID: unknown.ID},
  246. }}
  247. cfg := &config.Config{}
  248. cfg.Gateway.OpenAIWS.LBTopK = 1
  249. cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 1
  250. svc := &OpenAIGatewayService{
  251. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*preferred, *overflow, *unknown}},
  252. cfg: cfg,
  253. rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
  254. concurrencyService: NewConcurrencyService(cache),
  255. }
  256. groupID := int64(1)
  257. selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, true)
  258. require.NoError(t, err)
  259. require.Equal(t, overflow.ID, selection.Account.ID)
  260. require.Empty(t, cache.limits(preferred.ID))
  261. require.Equal(t, []int{1}, cache.limits(overflow.ID))
  262. require.Empty(t, cache.limits(unknown.ID))
  263. selection.ReleaseFunc()
  264. }
  265. func TestAdvancedSchedulerUnknownLoadFailsOpen(t *testing.T) {
  266. account := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  267. cache := &upstreamCostTrackingConcurrencyCache{}
  268. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{concurrencyService: NewConcurrencyService(cache)}}
  269. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, []openAIAccountCandidateScore{{
  270. account: account, loadInfo: &AccountLoadInfo{AccountID: account.ID, CurrentConcurrency: 99}, loadKnown: false,
  271. }})
  272. require.NoError(t, err)
  273. require.NotNil(t, selection)
  274. require.Equal(t, []int{1}, cache.limits(account.ID))
  275. selection.ReleaseFunc()
  276. }
  277. func TestAdvancedSchedulerReleasesSlotWhenDBDisablesCandidate(t *testing.T) {
  278. stale := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  279. backup := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  280. disabled := *stale
  281. disabled.Status = StatusDisabled
  282. repo := &upstreamCostCountingAccountRepo{accounts: map[int64]*Account{stale.ID: &disabled, backup.ID: backup}}
  283. snapshot := &openAISnapshotCacheStub{accountsByID: map[int64]*Account{stale.ID: stale, backup.ID: backup}}
  284. cache := &upstreamCostTrackingConcurrencyCache{}
  285. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  286. accountRepo: repo,
  287. schedulerSnapshot: &SchedulerSnapshotService{cache: snapshot},
  288. concurrencyService: NewConcurrencyService(cache),
  289. }}
  290. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, []openAIAccountCandidateScore{
  291. {account: stale, loadInfo: &AccountLoadInfo{AccountID: stale.ID}, loadKnown: true},
  292. {account: backup, loadInfo: &AccountLoadInfo{AccountID: backup.ID}, loadKnown: true},
  293. })
  294. require.NoError(t, err)
  295. require.Equal(t, backup.ID, selection.Account.ID)
  296. require.Equal(t, 1, cache.releaseCount(stale.ID))
  297. selection.ReleaseFunc()
  298. }
  299. func TestAdvancedSchedulerReacquiresOnceWhenDBConcurrencyChanges(t *testing.T) {
  300. stale := &Account{ID: 41, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 10}
  301. latest := *stale
  302. latest.Concurrency = 1
  303. repo := &upstreamCostCountingAccountRepo{accounts: map[int64]*Account{stale.ID: &latest}}
  304. snapshot := &openAISnapshotCacheStub{accountsByID: map[int64]*Account{stale.ID: stale}}
  305. cache := &upstreamCostTrackingConcurrencyCache{}
  306. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  307. accountRepo: repo,
  308. schedulerSnapshot: &SchedulerSnapshotService{cache: snapshot},
  309. concurrencyService: NewConcurrencyService(cache),
  310. }}
  311. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, []openAIAccountCandidateScore{{
  312. account: stale, loadInfo: &AccountLoadInfo{AccountID: stale.ID}, loadKnown: true,
  313. }})
  314. require.NoError(t, err)
  315. require.Equal(t, 1, selection.Account.Concurrency)
  316. require.Equal(t, []int{10, 1}, cache.limits(stale.ID))
  317. require.Equal(t, 1, cache.releaseCount(stale.ID))
  318. selection.ReleaseFunc()
  319. }
  320. func TestAdvancedSchedulerKnownFullPoolsDoNotRecheckDB(t *testing.T) {
  321. for _, size := range []int{100, 15_000} {
  322. t.Run(strconv.Itoa(size), func(t *testing.T) {
  323. accounts := make(map[int64]*Account, size)
  324. selectionOrder := make([]openAIAccountCandidateScore, 0, size)
  325. for i := 1; i <= size; i++ {
  326. account := &Account{ID: int64(i), Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1}
  327. accounts[account.ID] = account
  328. selectionOrder = append(selectionOrder, openAIAccountCandidateScore{
  329. account: account,
  330. loadInfo: &AccountLoadInfo{AccountID: account.ID, CurrentConcurrency: 1, LoadRate: 100},
  331. loadKnown: true,
  332. })
  333. }
  334. repo := &upstreamCostCountingAccountRepo{accounts: accounts}
  335. cache := &upstreamCostTrackingConcurrencyCache{}
  336. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  337. accountRepo: repo,
  338. schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{accountsByID: accounts}},
  339. concurrencyService: NewConcurrencyService(cache),
  340. }}
  341. selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, selectionOrder)
  342. require.NoError(t, err)
  343. require.Nil(t, selection)
  344. require.Zero(t, repo.calls())
  345. require.Zero(t, cache.totalAcquires())
  346. })
  347. }
  348. }
  349. func TestOpenAIFreshUpstreamBillingRateRecomputesPeakAtSelectionTime(t *testing.T) {
  350. receivedAt := time.Date(2026, 7, 13, 17, 30, 0, 0, time.UTC)
  351. account := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.4, receivedAt, time.Hour)
  352. snapshot, ok := account.Extra[UpstreamBillingProbeExtraKey].(map[string]any)
  353. require.True(t, ok)
  354. snapshot["data"] = map[string]any{
  355. "billing_scope": "token",
  356. "resolved_rate_multiplier": 0.4,
  357. "peak_rate_enabled": true,
  358. "peak_start": "09:00",
  359. "peak_end": "18:00",
  360. "peak_rate_multiplier": 2.0,
  361. "applied_peak_multiplier": 2.0,
  362. "effective_rate_multiplier": 0.8,
  363. "timezone": "UTC",
  364. }
  365. duringPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 17, 59, 0, 0, time.UTC))
  366. require.True(t, ok)
  367. require.Equal(t, 0.8, duringPeak)
  368. afterPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 18, 1, 0, 0, time.UTC))
  369. require.True(t, ok)
  370. require.Equal(t, 0.4, afterPeak)
  371. }
  372. func TestOpenAIUpstreamCostFactorsSparseProbeIsNeutral(t *testing.T) {
  373. now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
  374. accounts := make([]*Account, 0, 10)
  375. accounts = append(accounts, upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1, now.Add(-time.Minute), 30*time.Minute))
  376. for id := int64(2); id <= 10; id++ {
  377. accounts = append(accounts, &Account{
  378. ID: id,
  379. Platform: PlatformOpenAI,
  380. Type: AccountTypeAPIKey,
  381. Extra: map[string]any{
  382. UpstreamBillingProbeExtraKey: map[string]any{
  383. "status": UpstreamBillingProbeStatusFailed,
  384. "last_attempt_at": now.UTC().Format(time.RFC3339Nano),
  385. "next_probe_at": now.Add(time.Hour).UTC().Format(time.RFC3339Nano),
  386. },
  387. },
  388. })
  389. }
  390. factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  391. for id := int64(1); id <= 10; id++ {
  392. require.Equal(t, openAIUpstreamCostNeutralFactor, factors[id])
  393. }
  394. }
  395. func TestOpenAIUpstreamCostFactorsCoverageShrinksSparseSignal(t *testing.T) {
  396. now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
  397. accounts := []*Account{
  398. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute),
  399. upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute),
  400. }
  401. for id := int64(3); id <= 10; id++ {
  402. accounts = append(accounts, &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
  403. }
  404. factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  405. center := math.Sqrt(0.03 * 0.8)
  406. require.InDelta(t, 0.5+0.2*(1/(1+0.03/center)-0.5), factors[1], 1e-12)
  407. require.InDelta(t, 0.5+0.2*(1/(1+0.8/center)-0.5), factors[2], 1e-12)
  408. require.Equal(t, openAIUpstreamCostNeutralFactor, factors[3])
  409. }
  410. func TestOpenAIUpstreamCostFactorsUseMedianAgainstOutlier(t *testing.T) {
  411. now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
  412. accounts := []*Account{
  413. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.1, now.Add(-time.Minute), 30*time.Minute),
  414. upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute),
  415. upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 100, now.Add(-time.Minute), 30*time.Minute),
  416. }
  417. factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  418. require.InDelta(t, 2.0/3.0, factors[1], 1e-12)
  419. require.InDelta(t, 0.5, factors[2], 1e-12)
  420. require.InDelta(t, 1/(1+100/0.2), factors[3], 1e-12)
  421. }
  422. func TestOpenAILegacyUpstreamRateOrderRequiresComparableRates(t *testing.T) {
  423. now := time.Now()
  424. oneKnown := newOpenAILegacyUpstreamRateOrder([]*Account{
  425. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute),
  426. {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  427. }, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  428. require.False(t, oneKnown.enabled)
  429. allEqual := newOpenAILegacyUpstreamRateOrder([]*Account{
  430. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute),
  431. upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute),
  432. }, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  433. require.False(t, allEqual.enabled)
  434. distinct := newOpenAILegacyUpstreamRateOrder([]*Account{
  435. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute),
  436. upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute),
  437. {ID: 3, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  438. }, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  439. require.True(t, distinct.enabled)
  440. require.Negative(t, distinct.compare(&Account{ID: 1}, &Account{ID: 2}))
  441. require.Negative(t, distinct.compare(&Account{ID: 2}, &Account{ID: 3}))
  442. }
  443. // 探测资格已放宽到全部 API-key 平台,但调度侧的信任面没有跟着扩大:
  444. // 只有 OpenAI 平台账号的上游自报倍率参与 legacy 低倍率优先排序,
  445. // 否则中转方自报低价即可吸走流量,而实际结算走本地倍率。
  446. // 本用例钉死 newOpenAILegacyUpstreamRateOrder 与 openAIUpstreamCostFactors
  447. // 使用同一道平台门控。
  448. func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) {
  449. now := time.Now()
  450. nonOpenAI := func(id int64, platform string, rate float64) *Account {
  451. account := upstreamCostTestAccount(id, UpstreamBillingProbeStatusOK, rate, now.Add(-time.Minute), 30*time.Minute)
  452. account.Platform = platform
  453. return account
  454. }
  455. grokCheap := nonOpenAI(1, PlatformGrok, 0.01)
  456. anthropicExpensive := nonOpenAI(2, PlatformAnthropic, 0.9)
  457. order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  458. require.False(t, order.enabled)
  459. require.Empty(t, order.rates)
  460. require.Zero(t, order.compare(grokCheap, anthropicExpensive))
  461. factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, defaultOpenAIOAuthSchedulingRateMultiplier)
  462. require.Equal(t, openAIUpstreamCostNeutralFactor, factors[grokCheap.ID])
  463. require.Equal(t, openAIUpstreamCostNeutralFactor, factors[anthropicExpensive.ID])
  464. // 混合候选集里,非 OpenAI 账号既不进 rates 也不影响 OpenAI 账号之间的排序。
  465. openAICheap := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute)
  466. openAIExpensive := upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute)
  467. mixed := newOpenAILegacyUpstreamRateOrder(
  468. []*Account{grokCheap, openAICheap, anthropicExpensive, openAIExpensive},
  469. now, defaultOpenAIOAuthSchedulingRateMultiplier,
  470. )
  471. require.True(t, mixed.enabled)
  472. require.Len(t, mixed.rates, 2)
  473. require.NotContains(t, mixed.rates, grokCheap.ID)
  474. require.NotContains(t, mixed.rates, anthropicExpensive.ID)
  475. require.Negative(t, mixed.compare(openAICheap, openAIExpensive))
  476. // 自报 0.01 的 grok 账号没有已知倍率,排在有倍率的 OpenAI 账号之后。
  477. require.Positive(t, mixed.compare(grokCheap, openAIExpensive))
  478. }
  479. func TestOpenAISchedulingRatePlacesOAuthAtConfiguredReference(t *testing.T) {
  480. now := time.Now()
  481. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute)
  482. oauth := upstreamCostTestOAuthAccount(2)
  483. expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute)
  484. order := newOpenAILegacyUpstreamRateOrder([]*Account{cheap, oauth, expensive}, now, 0.05)
  485. require.True(t, order.enabled)
  486. require.Negative(t, order.compare(cheap, oauth))
  487. require.Negative(t, order.compare(oauth, expensive))
  488. factors := openAIUpstreamCostFactors([]*Account{cheap, oauth, expensive}, now, 0.05)
  489. require.Greater(t, factors[cheap.ID], factors[oauth.ID])
  490. require.Greater(t, factors[oauth.ID], factors[expensive.ID])
  491. }
  492. func TestOpenAIGatewayServiceLegacyLowRatePriorityUsesConfiguredOAuthReference(t *testing.T) {
  493. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  494. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  495. now := time.Now()
  496. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute)
  497. oauth := upstreamCostTestOAuthAccount(2)
  498. expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute)
  499. for _, account := range []*Account{cheap, oauth, expensive} {
  500. account.Status = StatusActive
  501. account.Schedulable = true
  502. account.Concurrency = 1
  503. }
  504. cheap.Priority, oauth.Priority, expensive.Priority = 20, 10, 0
  505. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  506. openAIAdvancedSchedulerSettingKey: "false",
  507. SettingKeyOpenAILowUpstreamRatePriorityEnabled: "true",
  508. SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05",
  509. }}
  510. cfg := &config.Config{}
  511. svc := &OpenAIGatewayService{
  512. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *oauth, *expensive}},
  513. cache: &schedulerTestGatewayCache{},
  514. cfg: cfg,
  515. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  516. }
  517. groupID := int64(1)
  518. first, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false)
  519. require.NoError(t, err)
  520. require.Equal(t, cheap.ID, first.Account.ID)
  521. second, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", map[int64]struct{}{cheap.ID: {}}, OpenAIUpstreamTransportAny, false)
  522. require.NoError(t, err)
  523. require.Equal(t, oauth.ID, second.Account.ID)
  524. }
  525. func TestOpenAIModelsSelectionIgnoresTokenCostSignal(t *testing.T) {
  526. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  527. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  528. now := time.Now()
  529. cheap := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute)
  530. expensive := upstreamCostTestAccount(52, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  531. for _, account := range []*Account{cheap, expensive} {
  532. account.Status = StatusActive
  533. account.Schedulable = true
  534. account.Concurrency = 1
  535. }
  536. cheap.Priority = 10
  537. expensive.Priority = 0
  538. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  539. SettingKeyOpenAILowUpstreamRatePriorityEnabled: "true",
  540. }}
  541. cfg := &config.Config{}
  542. svc := &OpenAIGatewayService{
  543. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}},
  544. cfg: cfg,
  545. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  546. }
  547. account, err := svc.SelectAccountForModelWithExclusions(context.Background(), nil, "", "", nil)
  548. require.NoError(t, err)
  549. require.Equal(t, expensive.ID, account.ID)
  550. }
  551. func TestOpenAIGatewayServiceLegacyLowRatePriorityIsIndependentFromAdvancedScheduler(t *testing.T) {
  552. now := time.Now()
  553. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute)
  554. cheap.Status, cheap.Schedulable, cheap.Concurrency, cheap.Priority = StatusActive, true, 1, 10
  555. expensive := upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  556. expensive.Status, expensive.Schedulable, expensive.Concurrency, expensive.Priority = StatusActive, true, 1, 0
  557. accounts := []Account{*cheap, *expensive}
  558. groupID := int64(1)
  559. tests := []struct {
  560. name string
  561. enabled bool
  562. loadBatch bool
  563. loadErr error
  564. wantID int64
  565. }{
  566. {name: "switch off keeps priority first", loadBatch: true, wantID: 2},
  567. {name: "load batch", enabled: true, loadBatch: true, wantID: 1},
  568. {name: "load batch disabled", enabled: true, wantID: 1},
  569. {name: "load lookup failure", enabled: true, loadBatch: true, loadErr: errors.New("load unavailable"), wantID: 1},
  570. }
  571. for _, tt := range tests {
  572. t.Run(tt.name, func(t *testing.T) {
  573. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  574. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  575. openAIAdvancedSchedulerSettingKey: "false",
  576. SettingKeyOpenAILowUpstreamRatePriorityEnabled: strconv.FormatBool(tt.enabled),
  577. }}
  578. cfg := &config.Config{}
  579. cfg.Gateway.Scheduling.LoadBatchEnabled = tt.loadBatch
  580. svc := &OpenAIGatewayService{
  581. accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
  582. cache: &schedulerTestGatewayCache{},
  583. cfg: cfg,
  584. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  585. concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{
  586. loadBatchErr: tt.loadErr,
  587. loadMap: map[int64]*AccountLoadInfo{
  588. 1: {AccountID: 1, LoadRate: 90},
  589. 2: {AccountID: 2, LoadRate: 10},
  590. },
  591. }),
  592. }
  593. selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false)
  594. require.NoError(t, err)
  595. require.Equal(t, tt.wantID, selection.Account.ID)
  596. if selection.ReleaseFunc != nil {
  597. selection.ReleaseFunc()
  598. }
  599. })
  600. }
  601. }
  602. func TestOpenAIGatewayServiceAdvancedSchedulerIgnoresLegacyLowRateSwitch(t *testing.T) {
  603. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  604. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  605. now := time.Now()
  606. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute)
  607. cheap.Status, cheap.Schedulable, cheap.Concurrency, cheap.Priority = StatusActive, true, 1, 10
  608. expensive := upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  609. expensive.Status, expensive.Schedulable, expensive.Concurrency, expensive.Priority = StatusActive, true, 1, 0
  610. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  611. openAIAdvancedSchedulerSettingKey: "true",
  612. SettingKeyOpenAILowUpstreamRatePriorityEnabled: "true",
  613. }}
  614. cfg := &config.Config{}
  615. cfg.Gateway.OpenAIWS.LBTopK = 1
  616. cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1
  617. svc := &OpenAIGatewayService{
  618. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}},
  619. cache: &schedulerTestGatewayCache{},
  620. cfg: cfg,
  621. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  622. concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
  623. }
  624. groupID := int64(1)
  625. selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false)
  626. require.NoError(t, err)
  627. require.Equal(t, int64(2), selection.Account.ID)
  628. if selection.ReleaseFunc != nil {
  629. selection.ReleaseFunc()
  630. }
  631. }
  632. func TestOpenAIGatewayServiceLegacyLowRatePrioritySkipsCooledDownAccount(t *testing.T) {
  633. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  634. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  635. now := time.Now()
  636. cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute)
  637. cheap.Status, cheap.Schedulable, cheap.Concurrency, cheap.Priority = StatusActive, true, 1, 10
  638. cooldownUntil := now.Add(time.Minute)
  639. cheap.TempUnschedulableUntil = &cooldownUntil
  640. expensive := upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute)
  641. expensive.Status, expensive.Schedulable, expensive.Concurrency, expensive.Priority = StatusActive, true, 1, 0
  642. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  643. openAIAdvancedSchedulerSettingKey: "false",
  644. SettingKeyOpenAILowUpstreamRatePriorityEnabled: "true",
  645. }}
  646. cfg := &config.Config{}
  647. cfg.Gateway.Scheduling.LoadBatchEnabled = true
  648. svc := &OpenAIGatewayService{
  649. accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}},
  650. cache: &schedulerTestGatewayCache{},
  651. cfg: cfg,
  652. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  653. concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{
  654. 1: {AccountID: 1},
  655. 2: {AccountID: 2},
  656. }}),
  657. }
  658. groupID := int64(1)
  659. selection, _, err := svc.SelectAccountWithScheduler(context.Background(), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false)
  660. require.NoError(t, err)
  661. require.Equal(t, int64(2), selection.Account.ID)
  662. if selection.ReleaseFunc != nil {
  663. selection.ReleaseFunc()
  664. }
  665. }
  666. func TestOpenAIFreshUpstreamBillingRateUsesFreshCachedSuccessOnly(t *testing.T) {
  667. now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
  668. tests := []struct {
  669. name string
  670. account *Account
  671. wantOK bool
  672. }{
  673. {name: "fresh", account: upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute), wantOK: true},
  674. {name: "zero rate", account: upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0, now.Add(-time.Minute), 30*time.Minute), wantOK: true},
  675. {name: "transient failure with fresh cache", account: upstreamCostTestAccount(3, UpstreamBillingProbeStatusFailed, 0.3, now.Add(-time.Minute), 30*time.Minute), wantOK: true},
  676. {name: "stale", account: upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.3, now.Add(-61*time.Minute), 30*time.Minute)},
  677. {name: "future", account: upstreamCostTestAccount(5, UpstreamBillingProbeStatusOK, 0.3, now.Add(time.Minute), 30*time.Minute)},
  678. {name: "unsupported", account: upstreamCostTestAccount(6, UpstreamBillingProbeStatusUnsupported, 0.3, now.Add(-time.Minute), 30*time.Minute)},
  679. }
  680. for _, tt := range tests {
  681. t.Run(tt.name, func(t *testing.T) {
  682. _, ok := openAIFreshUpstreamBillingRate(tt.account, now)
  683. require.Equal(t, tt.wantOK, ok)
  684. })
  685. }
  686. }
  687. func TestBuildOpenAISelectionOrderIncludesOverflowOnlyForCostScheduling(t *testing.T) {
  688. scheduler := &defaultOpenAIAccountScheduler{}
  689. candidates := []openAIAccountCandidateScore{
  690. {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, score: 3},
  691. {account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{}, score: 2},
  692. {account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{}, score: 1},
  693. }
  694. legacy := scheduler.buildOpenAISelectionOrder(OpenAIAccountScheduleRequest{}, openAIAccountLoadPlan{
  695. candidates: candidates,
  696. topK: 1,
  697. })
  698. require.Len(t, legacy, 1)
  699. costAware := scheduler.buildOpenAISelectionOrder(OpenAIAccountScheduleRequest{}, openAIAccountLoadPlan{
  700. candidates: candidates,
  701. topK: 1,
  702. includeOverflowFallback: true,
  703. })
  704. require.Equal(t, []int64{1, 2, 3}, []int64{
  705. costAware[0].account.ID,
  706. costAware[1].account.ID,
  707. costAware[2].account.ID,
  708. })
  709. }
  710. func TestBuildOpenAIAccountLoadPlanUsesCostOnlyForTokenScope(t *testing.T) {
  711. resetOpenAIAdvancedSchedulerSettingCacheForTest()
  712. defer resetOpenAIAdvancedSchedulerSettingCacheForTest()
  713. now := time.Now()
  714. accounts := []*Account{
  715. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute),
  716. upstreamCostTestOAuthAccount(2),
  717. upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute),
  718. }
  719. cfg := &config.Config{}
  720. cfg.Gateway.OpenAIWS.LBTopK = 1
  721. cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 1.5
  722. settings := &openAIAdvancedSchedulerSettingRepoStub{values: map[string]string{
  723. SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05",
  724. }}
  725. scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
  726. cfg: cfg,
  727. rateLimitService: &RateLimitService{settingService: NewSettingService(settings, cfg)},
  728. }}
  729. loadMap := map[int64]*AccountLoadInfo{
  730. 1: {AccountID: 1},
  731. 2: {AccountID: 2},
  732. 3: {AccountID: 3},
  733. }
  734. tokenPlan := scheduler.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{UseUpstreamTokenCost: true}, accounts, loadMap)
  735. require.Greater(t, tokenPlan.candidates[0].score, tokenPlan.candidates[1].score)
  736. require.Greater(t, tokenPlan.candidates[1].score, tokenPlan.candidates[2].score)
  737. require.True(t, tokenPlan.includeOverflowFallback)
  738. otherPlan := scheduler.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, accounts, loadMap)
  739. require.Equal(t, otherPlan.candidates[0].score, otherPlan.candidates[1].score)
  740. require.Equal(t, otherPlan.candidates[1].score, otherPlan.candidates[2].score)
  741. require.False(t, otherPlan.includeOverflowFallback)
  742. }
  743. func TestBuildOpenAIAccountSchedulerScoreSnapshotUpstreamCostIsExactNoOpWithoutSignal(t *testing.T) {
  744. accounts := []*Account{
  745. {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  746. {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  747. }
  748. loadMap := map[int64]*AccountLoadInfo{
  749. 1: {AccountID: 1, LoadRate: 20},
  750. 2: {AccountID: 2, LoadRate: 80},
  751. }
  752. weights := GatewayOpenAIWSSchedulerScoreWeightsView{Priority: 1, Load: 1, Queue: 0.7, ErrorRate: 0.8, TTFT: 0.5}
  753. baseline := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier)
  754. weights.UpstreamCost = 1.5
  755. withCost := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier)
  756. require.Equal(t, baseline, withCost)
  757. }
  758. func TestBuildOpenAIAccountSchedulerScoreSnapshotUsesUpstreamCostSignal(t *testing.T) {
  759. now := time.Now()
  760. accounts := []*Account{
  761. upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute),
  762. upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute),
  763. }
  764. weights := GatewayOpenAIWSSchedulerScoreWeightsView{UpstreamCost: 1.5}
  765. scores := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier)
  766. require.Greater(t, scores[1].BaseScore, scores[2].BaseScore)
  767. }