gateway_helper_hotpath_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. package handler
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "sync"
  8. "testing"
  9. "time"
  10. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type helperConcurrencyCacheStub struct {
  16. mu sync.Mutex
  17. accountSeq []bool
  18. userSeq []bool
  19. accountAcquireCalls int
  20. userAcquireCalls int
  21. accountReleaseCalls int
  22. userReleaseCalls int
  23. waitAllowed bool
  24. waitIncrementCalls int
  25. waitDecrementCalls int
  26. waitMaxWait int
  27. waitIncrementHook func()
  28. apiKeyTrackCalls int
  29. apiKeyReleaseCalls int
  30. apiKeyTrackIDs []int64
  31. }
  32. func (s *helperConcurrencyCacheStub) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
  33. s.mu.Lock()
  34. defer s.mu.Unlock()
  35. s.accountAcquireCalls++
  36. if len(s.accountSeq) == 0 {
  37. return false, nil
  38. }
  39. v := s.accountSeq[0]
  40. s.accountSeq = s.accountSeq[1:]
  41. return v, nil
  42. }
  43. func (s *helperConcurrencyCacheStub) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error {
  44. s.mu.Lock()
  45. defer s.mu.Unlock()
  46. s.accountReleaseCalls++
  47. return nil
  48. }
  49. func (s *helperConcurrencyCacheStub) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) {
  50. return 0, nil
  51. }
  52. func (s *helperConcurrencyCacheStub) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
  53. out := make(map[int64]int, len(accountIDs))
  54. for _, accountID := range accountIDs {
  55. out[accountID] = 0
  56. }
  57. return out, nil
  58. }
  59. func (s *helperConcurrencyCacheStub) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) {
  60. return true, nil
  61. }
  62. func (s *helperConcurrencyCacheStub) DecrementAccountWaitCount(ctx context.Context, accountID int64) error {
  63. return nil
  64. }
  65. func (s *helperConcurrencyCacheStub) GetAccountWaitingCount(ctx context.Context, accountID int64) (int, error) {
  66. return 0, nil
  67. }
  68. func (s *helperConcurrencyCacheStub) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
  69. s.mu.Lock()
  70. defer s.mu.Unlock()
  71. s.userAcquireCalls++
  72. if len(s.userSeq) == 0 {
  73. return false, nil
  74. }
  75. v := s.userSeq[0]
  76. s.userSeq = s.userSeq[1:]
  77. return v, nil
  78. }
  79. func (s *helperConcurrencyCacheStub) ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error {
  80. s.mu.Lock()
  81. defer s.mu.Unlock()
  82. s.userReleaseCalls++
  83. return nil
  84. }
  85. func (s *helperConcurrencyCacheStub) GetUserConcurrency(ctx context.Context, userID int64) (int, error) {
  86. return 0, nil
  87. }
  88. func (s *helperConcurrencyCacheStub) TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
  89. s.mu.Lock()
  90. defer s.mu.Unlock()
  91. s.apiKeyTrackCalls++
  92. s.apiKeyTrackIDs = append(s.apiKeyTrackIDs, apiKeyID)
  93. return nil
  94. }
  95. func (s *helperConcurrencyCacheStub) ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
  96. s.mu.Lock()
  97. defer s.mu.Unlock()
  98. s.apiKeyReleaseCalls++
  99. return nil
  100. }
  101. func (s *helperConcurrencyCacheStub) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) {
  102. out := make(map[int64]int, len(apiKeyIDs))
  103. for _, apiKeyID := range apiKeyIDs {
  104. out[apiKeyID] = 0
  105. }
  106. return out, nil
  107. }
  108. func (s *helperConcurrencyCacheStub) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
  109. s.mu.Lock()
  110. s.waitIncrementCalls++
  111. s.waitMaxWait = maxWait
  112. waitAllowed := s.waitAllowed
  113. hook := s.waitIncrementHook
  114. s.mu.Unlock()
  115. if hook != nil {
  116. hook()
  117. }
  118. if !waitAllowed {
  119. return false, nil
  120. }
  121. return true, nil
  122. }
  123. func (s *helperConcurrencyCacheStub) DecrementWaitCount(ctx context.Context, userID int64) error {
  124. s.mu.Lock()
  125. defer s.mu.Unlock()
  126. s.waitDecrementCalls++
  127. return nil
  128. }
  129. func (s *helperConcurrencyCacheStub) GetAccountsLoadBatch(ctx context.Context, accounts []service.AccountWithConcurrency) (map[int64]*service.AccountLoadInfo, error) {
  130. out := make(map[int64]*service.AccountLoadInfo, len(accounts))
  131. for _, acc := range accounts {
  132. out[acc.ID] = &service.AccountLoadInfo{AccountID: acc.ID}
  133. }
  134. return out, nil
  135. }
  136. func (s *helperConcurrencyCacheStub) GetUsersLoadBatch(ctx context.Context, users []service.UserWithConcurrency) (map[int64]*service.UserLoadInfo, error) {
  137. out := make(map[int64]*service.UserLoadInfo, len(users))
  138. for _, user := range users {
  139. out[user.ID] = &service.UserLoadInfo{UserID: user.ID}
  140. }
  141. return out, nil
  142. }
  143. func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error {
  144. return nil
  145. }
  146. func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
  147. return nil
  148. }
  149. func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
  150. return nil
  151. }
  152. func newHelperTestContext(method, path string) (*gin.Context, *httptest.ResponseRecorder) {
  153. gin.SetMode(gin.TestMode)
  154. rec := httptest.NewRecorder()
  155. c, _ := gin.CreateTestContext(rec)
  156. c.Request = httptest.NewRequest(method, path, nil)
  157. return c, rec
  158. }
  159. func validClaudeCodeBodyJSON() []byte {
  160. return []byte(`{
  161. "model":"claude-3-5-sonnet-20241022",
  162. "system":[{"text":"You are Claude Code, Anthropic's official CLI for Claude."}],
  163. "metadata":{"user_id":"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
  164. }`)
  165. }
  166. func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
  167. t.Run("non_cli_user_agent_sets_false", func(t *testing.T) {
  168. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  169. c.Request.Header.Set("User-Agent", "curl/8.6.0")
  170. SetClaudeCodeClientContext(c, validClaudeCodeBodyJSON(), nil)
  171. require.False(t, service.IsClaudeCodeClient(c.Request.Context()))
  172. })
  173. t.Run("cli_non_messages_path_sets_true", func(t *testing.T) {
  174. c, _ := newHelperTestContext(http.MethodGet, "/v1/models")
  175. c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
  176. SetClaudeCodeClientContext(c, nil, nil)
  177. require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
  178. })
  179. t.Run("cli_messages_path_valid_body_sets_true", func(t *testing.T) {
  180. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  181. c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
  182. c.Request.Header.Set("X-App", "claude-code")
  183. c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
  184. c.Request.Header.Set("anthropic-version", "2023-06-01")
  185. SetClaudeCodeClientContext(c, validClaudeCodeBodyJSON(), nil)
  186. require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
  187. })
  188. t.Run("cli_messages_path_invalid_body_sets_false", func(t *testing.T) {
  189. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  190. c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
  191. // 缺少严格校验所需 header + body 字段
  192. SetClaudeCodeClientContext(c, []byte(`{"model":"x"}`), nil)
  193. require.False(t, service.IsClaudeCodeClient(c.Request.Context()))
  194. })
  195. }
  196. func TestSetClaudeCodeClientContext_ReuseParsedRequest(t *testing.T) {
  197. t.Run("reuse parsed request without body unmarshal", func(t *testing.T) {
  198. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  199. c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
  200. c.Request.Header.Set("X-App", "claude-code")
  201. c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
  202. c.Request.Header.Set("anthropic-version", "2023-06-01")
  203. parsedReq, err := service.ParseGatewayRequest(service.NewRequestBodyRef(validClaudeCodeBodyJSON()), "")
  204. require.NoError(t, err)
  205. // body 非法 JSON,如果函数复用 parsedReq 成功则仍应判定为 Claude Code。
  206. SetClaudeCodeClientContext(c, []byte(`{invalid`), parsedReq)
  207. require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
  208. })
  209. }
  210. func TestWaitForSlotWithPingTimeout_AccountAndUserAcquire(t *testing.T) {
  211. cache := &helperConcurrencyCacheStub{
  212. accountSeq: []bool{false, true},
  213. userSeq: []bool{false, true},
  214. }
  215. concurrency := service.NewConcurrencyService(cache)
  216. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  217. t.Run("account_slot_acquired_after_retry", func(t *testing.T) {
  218. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  219. streamStarted := false
  220. release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, time.Second, false, &streamStarted, true)
  221. require.NoError(t, err)
  222. require.NotNil(t, release)
  223. require.False(t, streamStarted)
  224. release()
  225. require.GreaterOrEqual(t, cache.accountAcquireCalls, 2)
  226. require.GreaterOrEqual(t, cache.accountReleaseCalls, 1)
  227. })
  228. t.Run("user_slot_acquired_after_retry", func(t *testing.T) {
  229. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  230. streamStarted := false
  231. release, err := helper.waitForSlotWithPingTimeout(c, "user", 202, 3, time.Second, false, &streamStarted, true)
  232. require.NoError(t, err)
  233. require.NotNil(t, release)
  234. release()
  235. require.GreaterOrEqual(t, cache.userAcquireCalls, 2)
  236. require.GreaterOrEqual(t, cache.userReleaseCalls, 1)
  237. })
  238. }
  239. func TestAcquireUserSlotWithWait_ImmediateAcquireSkipsWaitQueue(t *testing.T) {
  240. cache := &helperConcurrencyCacheStub{
  241. userSeq: []bool{true},
  242. }
  243. concurrency := service.NewConcurrencyService(cache)
  244. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  245. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  246. streamStarted := false
  247. release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
  248. require.NoError(t, err)
  249. require.NotNil(t, release)
  250. release()
  251. require.Equal(t, 1, cache.userAcquireCalls)
  252. require.Equal(t, 0, cache.waitIncrementCalls)
  253. require.Equal(t, 0, cache.waitDecrementCalls)
  254. require.Equal(t, 1, cache.userReleaseCalls)
  255. }
  256. func TestAcquireUserSlotWithWait_TracksAPIKeySlot(t *testing.T) {
  257. cache := &helperConcurrencyCacheStub{
  258. userSeq: []bool{true},
  259. }
  260. concurrency := service.NewConcurrencyService(cache)
  261. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  262. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  263. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 77})
  264. streamStarted := false
  265. release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
  266. require.NoError(t, err)
  267. require.NotNil(t, release)
  268. require.Equal(t, 1, cache.apiKeyTrackCalls)
  269. require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
  270. release()
  271. require.Equal(t, 1, cache.userReleaseCalls)
  272. require.Equal(t, 1, cache.apiKeyReleaseCalls)
  273. }
  274. func TestTryAcquireUserSlotForAPIKey_TracksAPIKeySlot(t *testing.T) {
  275. cache := &helperConcurrencyCacheStub{
  276. userSeq: []bool{true},
  277. }
  278. concurrency := service.NewConcurrencyService(cache)
  279. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  280. release, acquired, err := helper.TryAcquireUserSlotForAPIKey(context.Background(), 202, 3, 77)
  281. require.NoError(t, err)
  282. require.True(t, acquired)
  283. require.NotNil(t, release)
  284. require.Equal(t, 1, cache.apiKeyTrackCalls)
  285. require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
  286. release()
  287. require.Equal(t, 1, cache.userReleaseCalls)
  288. require.Equal(t, 1, cache.apiKeyReleaseCalls)
  289. }
  290. func TestAcquireUserSlotWithWait_WaitSuccessDecrementsBeforeReturn(t *testing.T) {
  291. cache := &helperConcurrencyCacheStub{
  292. userSeq: []bool{false, true},
  293. waitAllowed: true,
  294. }
  295. concurrency := service.NewConcurrencyService(cache)
  296. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  297. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  298. streamStarted := false
  299. release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
  300. require.NoError(t, err)
  301. require.NotNil(t, release)
  302. require.Equal(t, 2, cache.userAcquireCalls)
  303. require.Equal(t, 1, cache.waitIncrementCalls)
  304. require.Equal(t, 20, cache.waitMaxWait)
  305. require.Equal(t, 1, cache.waitDecrementCalls)
  306. release()
  307. require.Equal(t, 1, cache.userReleaseCalls)
  308. }
  309. func TestAcquireUserSlotWithWait_TimeoutDecrementsWaitQueue(t *testing.T) {
  310. cache := &helperConcurrencyCacheStub{
  311. userSeq: []bool{false, false, false},
  312. waitAllowed: true,
  313. }
  314. concurrency := service.NewConcurrencyService(cache)
  315. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  316. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  317. streamStarted := false
  318. release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, 30*time.Millisecond, false, &streamStarted)
  319. require.Nil(t, release)
  320. var cErr *ConcurrencyError
  321. require.ErrorAs(t, err, &cErr)
  322. require.True(t, cErr.IsTimeout)
  323. require.Equal(t, 1, cache.waitIncrementCalls)
  324. require.Equal(t, 1, cache.waitDecrementCalls)
  325. require.Equal(t, 0, cache.userReleaseCalls)
  326. }
  327. func TestAcquireUserSlotWithWait_RequestCancelDecrementsWaitQueue(t *testing.T) {
  328. cancelled := make(chan struct{})
  329. var cancel context.CancelFunc
  330. cache := &helperConcurrencyCacheStub{
  331. userSeq: []bool{false, false},
  332. waitAllowed: true,
  333. waitIncrementHook: func() {
  334. cancel()
  335. close(cancelled)
  336. },
  337. }
  338. concurrency := service.NewConcurrencyService(cache)
  339. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  340. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  341. reqCtx, cancelFunc := context.WithCancel(c.Request.Context())
  342. cancel = cancelFunc
  343. defer cancel()
  344. c.Request = c.Request.WithContext(reqCtx)
  345. streamStarted := false
  346. release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
  347. <-cancelled
  348. require.Nil(t, release)
  349. require.ErrorIs(t, err, context.Canceled)
  350. require.Equal(t, 1, cache.waitIncrementCalls)
  351. require.Equal(t, 1, cache.waitDecrementCalls)
  352. require.Equal(t, 0, cache.userReleaseCalls)
  353. }
  354. func TestWaitForSlotWithPingTimeout_TimeoutAndStreamPing(t *testing.T) {
  355. cache := &helperConcurrencyCacheStub{
  356. accountSeq: []bool{false, false, false},
  357. }
  358. concurrency := service.NewConcurrencyService(cache)
  359. t.Run("timeout_returns_concurrency_error", func(t *testing.T) {
  360. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  361. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  362. streamStarted := false
  363. release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, 130*time.Millisecond, false, &streamStarted, true)
  364. require.Nil(t, release)
  365. var cErr *ConcurrencyError
  366. require.ErrorAs(t, err, &cErr)
  367. require.True(t, cErr.IsTimeout)
  368. })
  369. t.Run("stream_mode_sends_ping_before_timeout", func(t *testing.T) {
  370. helper := NewConcurrencyHelper(concurrency, SSEPingFormatComment, 10*time.Millisecond)
  371. c, rec := newHelperTestContext(http.MethodPost, "/v1/messages")
  372. streamStarted := false
  373. release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, 70*time.Millisecond, true, &streamStarted, true)
  374. require.Nil(t, release)
  375. var cErr *ConcurrencyError
  376. require.ErrorAs(t, err, &cErr)
  377. require.True(t, cErr.IsTimeout)
  378. require.True(t, streamStarted)
  379. require.Contains(t, rec.Body.String(), ":\n\n")
  380. })
  381. }
  382. func TestWaitForSlotWithPingTimeout_ParentContextCanceled(t *testing.T) {
  383. cache := &helperConcurrencyCacheStub{
  384. accountSeq: []bool{false},
  385. }
  386. concurrency := service.NewConcurrencyService(cache)
  387. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  388. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  389. reqCtx, cancel := context.WithCancel(c.Request.Context())
  390. c.Request = c.Request.WithContext(reqCtx)
  391. cancel()
  392. streamStarted := false
  393. release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, time.Second, false, &streamStarted, true)
  394. require.Nil(t, release)
  395. require.ErrorIs(t, err, context.Canceled)
  396. var cErr *ConcurrencyError
  397. require.False(t, errors.As(err, &cErr))
  398. }
  399. func TestWaitForSlotWithPingTimeout_AcquireError(t *testing.T) {
  400. errCache := &helperConcurrencyCacheStubWithError{
  401. err: errors.New("redis unavailable"),
  402. }
  403. concurrency := service.NewConcurrencyService(errCache)
  404. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  405. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  406. streamStarted := false
  407. release, err := helper.waitForSlotWithPingTimeout(c, "account", 1, 1, 200*time.Millisecond, false, &streamStarted, true)
  408. require.Nil(t, release)
  409. require.Error(t, err)
  410. require.Contains(t, err.Error(), "redis unavailable")
  411. }
  412. func TestAcquireAccountSlotWithWaitTimeout_ImmediateAttemptBeforeBackoff(t *testing.T) {
  413. cache := &helperConcurrencyCacheStub{
  414. accountSeq: []bool{false},
  415. }
  416. concurrency := service.NewConcurrencyService(cache)
  417. helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
  418. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  419. streamStarted := false
  420. release, err := helper.AcquireAccountSlotWithWaitTimeout(c, 301, 1, 30*time.Millisecond, false, &streamStarted)
  421. require.Nil(t, release)
  422. var cErr *ConcurrencyError
  423. require.ErrorAs(t, err, &cErr)
  424. require.True(t, cErr.IsTimeout)
  425. require.GreaterOrEqual(t, cache.accountAcquireCalls, 1)
  426. }
  427. type helperConcurrencyCacheStubWithError struct {
  428. helperConcurrencyCacheStub
  429. err error
  430. }
  431. func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
  432. return false, s.err
  433. }
  434. func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
  435. c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  436. c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
  437. // The hot path reuses ParsedRequest instead of re-parsing the body; the probe
  438. // marker must survive that projection or the exemption only works cold.
  439. parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
  440. SetClaudeCodeClientContext(c, nil, parsed)
  441. require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
  442. c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
  443. c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
  444. SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
  445. require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
  446. }