account_test_service_openai_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. "github.com/tidwall/gjson"
  18. )
  19. // --- shared test helpers ---
  20. type queuedHTTPUpstream struct {
  21. responses []*http.Response
  22. requests []*http.Request
  23. tlsFlags []bool
  24. }
  25. func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  26. return nil, fmt.Errorf("unexpected Do call")
  27. }
  28. func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  29. u.requests = append(u.requests, req)
  30. u.tlsFlags = append(u.tlsFlags, profile != nil)
  31. if len(u.responses) == 0 {
  32. return nil, fmt.Errorf("no mocked response")
  33. }
  34. resp := u.responses[0]
  35. u.responses = u.responses[1:]
  36. return resp, nil
  37. }
  38. func newJSONResponse(status int, body string) *http.Response {
  39. return &http.Response{
  40. StatusCode: status,
  41. Header: make(http.Header),
  42. Body: io.NopCloser(strings.NewReader(body)),
  43. }
  44. }
  45. // --- test functions ---
  46. func newTestContext() (*gin.Context, *httptest.ResponseRecorder) {
  47. gin.SetMode(gin.TestMode)
  48. rec := httptest.NewRecorder()
  49. c, _ := gin.CreateTestContext(rec)
  50. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
  51. return c, rec
  52. }
  53. type openAIAccountTestRepo struct {
  54. mockAccountRepoForGemini
  55. updatedExtra map[string]any
  56. bulkUpdatedIDs []int64
  57. bulkUpdatedPayload AccountBulkUpdate
  58. rateLimitedID int64
  59. rateLimitedAt *time.Time
  60. clearedErrorID int64
  61. setErrorID int64
  62. setErrorMsg string
  63. }
  64. func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
  65. r.updatedExtra = updates
  66. return nil
  67. }
  68. func (r *openAIAccountTestRepo) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
  69. r.bulkUpdatedIDs = append([]int64(nil), ids...)
  70. r.bulkUpdatedPayload = updates
  71. return int64(len(ids)), nil
  72. }
  73. func (r *openAIAccountTestRepo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
  74. r.rateLimitedID = id
  75. r.rateLimitedAt = &resetAt
  76. return nil
  77. }
  78. func (r *openAIAccountTestRepo) ClearError(_ context.Context, id int64) error {
  79. r.clearedErrorID = id
  80. return nil
  81. }
  82. func (r *openAIAccountTestRepo) SetError(_ context.Context, id int64, errorMsg string) error {
  83. r.setErrorID = id
  84. r.setErrorMsg = errorMsg
  85. return nil
  86. }
  87. func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) {
  88. gin.SetMode(gin.TestMode)
  89. ctx, recorder := newTestContext()
  90. resp := newJSONResponse(http.StatusOK, "")
  91. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  92. `))
  93. resp.Header.Set("x-codex-primary-used-percent", "88")
  94. resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
  95. resp.Header.Set("x-codex-primary-window-minutes", "10080")
  96. resp.Header.Set("x-codex-secondary-used-percent", "42")
  97. resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
  98. resp.Header.Set("x-codex-secondary-window-minutes", "300")
  99. repo := &openAIAccountTestRepo{}
  100. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  101. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  102. account := &Account{
  103. ID: 89,
  104. Platform: PlatformOpenAI,
  105. Type: AccountTypeOAuth,
  106. Concurrency: 1,
  107. Credentials: map[string]any{"access_token": "test-token"},
  108. }
  109. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  110. require.NoError(t, err)
  111. require.Len(t, upstream.requests, 1)
  112. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[0].Context()))
  113. require.NotEmpty(t, repo.updatedExtra)
  114. require.Equal(t, 42.0, repo.updatedExtra["codex_5h_used_percent"])
  115. require.Equal(t, 88.0, repo.updatedExtra["codex_7d_used_percent"])
  116. require.Contains(t, recorder.Body.String(), "test_complete")
  117. }
  118. func TestAccountTestService_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) {
  119. gin.SetMode(gin.TestMode)
  120. ctx, _ := newTestContext()
  121. resp := newJSONResponse(http.StatusOK, "")
  122. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  123. `))
  124. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  125. svc := &AccountTestService{httpUpstream: upstream}
  126. account := &Account{
  127. ID: 90,
  128. Platform: PlatformOpenAI,
  129. Type: AccountTypeOAuth,
  130. Concurrency: 1,
  131. Credentials: map[string]any{"access_token": "test-token"},
  132. }
  133. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.6", "", "")
  134. require.NoError(t, err)
  135. require.Len(t, upstream.requests, 1)
  136. body, err := io.ReadAll(upstream.requests[0].Body)
  137. require.NoError(t, err)
  138. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String())
  139. }
  140. func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) {
  141. gin.SetMode(gin.TestMode)
  142. ctx, recorder := newTestContext()
  143. resp := newJSONResponse(http.StatusOK, "")
  144. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  145. `))
  146. parentID := int64(100)
  147. parent := &Account{
  148. ID: parentID,
  149. Platform: PlatformOpenAI,
  150. Type: AccountTypeOAuth,
  151. Status: StatusActive,
  152. Credentials: map[string]any{
  153. "access_token": "parent-token",
  154. "chatgpt_account_id": "org-parent",
  155. },
  156. }
  157. shadow := &Account{
  158. ID: 200,
  159. Platform: PlatformOpenAI,
  160. Type: AccountTypeOAuth,
  161. Status: StatusActive,
  162. ParentAccountID: &parentID,
  163. QuotaDimension: QuotaDimensionSpark,
  164. Concurrency: 2,
  165. Credentials: map[string]any{
  166. "model_mapping": map[string]any{
  167. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  168. },
  169. },
  170. }
  171. repo := &openAIAccountTestRepo{
  172. mockAccountRepoForGemini: mockAccountRepoForGemini{
  173. accountsByID: map[int64]*Account{
  174. parentID: parent,
  175. 200: shadow,
  176. },
  177. },
  178. }
  179. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  180. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  181. err := svc.TestAccountConnection(ctx, shadow.ID, "gpt-5.3-codex-spark", "", "")
  182. require.NoError(t, err)
  183. require.Len(t, upstream.requests, 1)
  184. req := upstream.requests[0]
  185. require.Equal(t, "Bearer parent-token", req.Header.Get("Authorization"))
  186. require.Equal(t, "org-parent", req.Header.Get("chatgpt-account-id"))
  187. body, err := io.ReadAll(req.Body)
  188. require.NoError(t, err)
  189. require.Equal(t, "gpt-5.3-codex-spark", gjson.GetBytes(body, "model").String())
  190. require.Contains(t, recorder.Body.String(), `"success":true`)
  191. }
  192. func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) {
  193. gin.SetMode(gin.TestMode)
  194. ctx, recorder := newTestContext()
  195. resp := newJSONResponse(http.StatusOK, "")
  196. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"hi"}
  197. `))
  198. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  199. svc := &AccountTestService{httpUpstream: upstream}
  200. account := &Account{
  201. ID: 90,
  202. Platform: PlatformOpenAI,
  203. Type: AccountTypeOAuth,
  204. Concurrency: 1,
  205. Credentials: map[string]any{"access_token": "test-token"},
  206. }
  207. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  208. require.Error(t, err)
  209. require.Contains(t, recorder.Body.String(), "response.completed")
  210. require.NotContains(t, recorder.Body.String(), `"success":true`)
  211. }
  212. func TestAccountTestService_DeepSeekCustomBaseURLUsesV1ResponsesPath(t *testing.T) {
  213. gin.SetMode(gin.TestMode)
  214. ctx, _ := newTestContext()
  215. resp := newJSONResponse(http.StatusOK, "")
  216. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  217. `))
  218. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  219. svc := &AccountTestService{
  220. httpUpstream: upstream,
  221. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  222. }
  223. account := &Account{
  224. ID: 91,
  225. Platform: PlatformDeepseek,
  226. Type: AccountTypeAPIKey,
  227. Concurrency: 1,
  228. Credentials: map[string]any{
  229. "api_key": "sk-test",
  230. "base_url": "https://relay.example.com/v1",
  231. "api_protocol": APIProtocolResponses,
  232. },
  233. Extra: map[string]any{
  234. openai_compat.ExtraKeyResponsesSupported: true,
  235. },
  236. }
  237. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  238. require.NoError(t, err)
  239. require.Len(t, upstream.requests, 1)
  240. require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String())
  241. }
  242. func TestAccountTestService_DeepSeekResponsesRoutesToOpenAIProbe(t *testing.T) {
  243. gin.SetMode(gin.TestMode)
  244. ctx, _ := newTestContext()
  245. resp := newJSONResponse(http.StatusOK, "")
  246. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  247. `))
  248. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  249. svc := &AccountTestService{
  250. httpUpstream: upstream,
  251. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  252. }
  253. account := &Account{
  254. ID: 93,
  255. Platform: PlatformDeepseek,
  256. Type: AccountTypeAPIKey,
  257. Concurrency: 1,
  258. Credentials: map[string]any{
  259. "api_key": "sk-test",
  260. "base_url": "https://relay.example.com/v1",
  261. "api_protocol": APIProtocolResponses,
  262. },
  263. Extra: map[string]any{
  264. openai_compat.ExtraKeyResponsesSupported: true,
  265. },
  266. }
  267. repo := &openAIAccountTestRepo{
  268. mockAccountRepoForGemini: mockAccountRepoForGemini{
  269. accountsByID: map[int64]*Account{93: account},
  270. },
  271. }
  272. svc.accountRepo = repo
  273. err := svc.TestAccountConnection(ctx, account.ID, "gpt-5.4", "", "")
  274. require.NoError(t, err)
  275. require.Len(t, upstream.requests, 1)
  276. require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String())
  277. }
  278. func TestAccountTestService_DeepSeekDefaultBaseURLUsesNativeResponsesPath(t *testing.T) {
  279. gin.SetMode(gin.TestMode)
  280. ctx, _ := newTestContext()
  281. resp := newJSONResponse(http.StatusOK, "")
  282. resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
  283. `))
  284. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  285. svc := &AccountTestService{
  286. httpUpstream: upstream,
  287. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  288. }
  289. account := &Account{
  290. ID: 92,
  291. Platform: PlatformDeepseek,
  292. Type: AccountTypeAPIKey,
  293. Concurrency: 1,
  294. Credentials: map[string]any{
  295. "api_key": "sk-test",
  296. "api_protocol": APIProtocolResponses,
  297. },
  298. Extra: map[string]any{
  299. openai_compat.ExtraKeyResponsesSupported: true,
  300. },
  301. }
  302. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  303. require.NoError(t, err)
  304. require.Len(t, upstream.requests, 1)
  305. require.Equal(t, "https://api.deepseek.com/responses", upstream.requests[0].URL.String())
  306. }
  307. func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) {
  308. gin.SetMode(gin.TestMode)
  309. ctx, _ := newTestContext()
  310. resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`)
  311. resp.Header.Set("x-codex-primary-used-percent", "100")
  312. resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
  313. resp.Header.Set("x-codex-primary-window-minutes", "10080")
  314. resp.Header.Set("x-codex-secondary-used-percent", "100")
  315. resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
  316. resp.Header.Set("x-codex-secondary-window-minutes", "300")
  317. repo := &openAIAccountTestRepo{}
  318. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  319. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  320. account := &Account{
  321. ID: 88,
  322. Platform: PlatformOpenAI,
  323. Type: AccountTypeOAuth,
  324. Status: StatusError,
  325. Concurrency: 1,
  326. Credentials: map[string]any{"access_token": "test-token"},
  327. }
  328. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  329. require.Error(t, err)
  330. require.NotEmpty(t, repo.updatedExtra)
  331. require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"])
  332. require.Equal(t, account.ID, repo.rateLimitedID)
  333. require.NotNil(t, repo.rateLimitedAt)
  334. require.Equal(t, account.ID, repo.clearedErrorID)
  335. require.Equal(t, StatusActive, account.Status)
  336. require.Empty(t, account.ErrorMessage)
  337. require.NotNil(t, account.RateLimitResetAt)
  338. }
  339. func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) {
  340. gin.SetMode(gin.TestMode)
  341. ctx, _ := newTestContext()
  342. resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":"1777283883"}}`)
  343. repo := &openAIAccountTestRepo{}
  344. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  345. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  346. account := &Account{
  347. ID: 77,
  348. Platform: PlatformOpenAI,
  349. Type: AccountTypeOAuth,
  350. Status: StatusError,
  351. ErrorMessage: "Access forbidden (403): account may be suspended or lack permissions",
  352. Concurrency: 1,
  353. Credentials: map[string]any{"access_token": "test-token"},
  354. }
  355. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  356. require.Error(t, err)
  357. require.Equal(t, account.ID, repo.rateLimitedID)
  358. require.NotNil(t, repo.rateLimitedAt)
  359. require.Equal(t, account.ID, repo.clearedErrorID)
  360. require.Equal(t, StatusActive, account.Status)
  361. require.Empty(t, account.ErrorMessage)
  362. require.NotNil(t, account.RateLimitResetAt)
  363. require.Empty(t, repo.updatedExtra)
  364. }
  365. func TestAccountTestService_OpenAI429SyncsObservedPlanType(t *testing.T) {
  366. gin.SetMode(gin.TestMode)
  367. ctx, _ := newTestContext()
  368. resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","plan_type":"free","resets_at":1777283883}}`)
  369. repo := &openAIAccountTestRepo{}
  370. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  371. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  372. account := &Account{
  373. ID: 81,
  374. Platform: PlatformOpenAI,
  375. Type: AccountTypeOAuth,
  376. Status: StatusActive,
  377. Concurrency: 1,
  378. Credentials: map[string]any{"access_token": "test-token", "plan_type": "plus"},
  379. }
  380. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  381. require.Error(t, err)
  382. require.Equal(t, []int64{account.ID}, repo.bulkUpdatedIDs)
  383. require.Equal(t, "free", repo.bulkUpdatedPayload.Credentials["plan_type"])
  384. require.Equal(t, "free", account.Credentials["plan_type"])
  385. require.Equal(t, account.ID, repo.rateLimitedID)
  386. require.NotNil(t, account.RateLimitResetAt)
  387. }
  388. func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T) {
  389. gin.SetMode(gin.TestMode)
  390. ctx, _ := newTestContext()
  391. resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_in_seconds":3600}}`)
  392. repo := &openAIAccountTestRepo{}
  393. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  394. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  395. account := &Account{
  396. ID: 78,
  397. Platform: PlatformOpenAI,
  398. Type: AccountTypeOAuth,
  399. Status: StatusActive,
  400. Concurrency: 1,
  401. Credentials: map[string]any{"access_token": "test-token"},
  402. }
  403. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  404. require.Error(t, err)
  405. require.Equal(t, account.ID, repo.rateLimitedID)
  406. require.NotNil(t, repo.rateLimitedAt)
  407. require.Zero(t, repo.clearedErrorID)
  408. require.Equal(t, StatusActive, account.Status)
  409. require.NotNil(t, account.RateLimitResetAt)
  410. }
  411. func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState(t *testing.T) {
  412. gin.SetMode(gin.TestMode)
  413. ctx, _ := newTestContext()
  414. resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`)
  415. repo := &openAIAccountTestRepo{}
  416. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  417. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  418. account := &Account{
  419. ID: 79,
  420. Platform: PlatformOpenAI,
  421. Type: AccountTypeOAuth,
  422. Status: StatusError,
  423. ErrorMessage: "stale 403",
  424. Concurrency: 1,
  425. Credentials: map[string]any{"access_token": "test-token"},
  426. }
  427. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  428. require.Error(t, err)
  429. require.Zero(t, repo.rateLimitedID)
  430. require.Nil(t, repo.rateLimitedAt)
  431. require.Zero(t, repo.clearedErrorID)
  432. require.Equal(t, StatusError, account.Status)
  433. require.Equal(t, "stale 403", account.ErrorMessage)
  434. require.Nil(t, account.RateLimitResetAt)
  435. }
  436. func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) {
  437. gin.SetMode(gin.TestMode)
  438. ctx, _ := newTestContext()
  439. resp := newJSONResponse(http.StatusUnauthorized, `{"error":"bad token"}`)
  440. repo := &openAIAccountTestRepo{}
  441. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  442. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  443. account := &Account{
  444. ID: 80,
  445. Platform: PlatformOpenAI,
  446. Type: AccountTypeOAuth,
  447. Status: StatusActive,
  448. Concurrency: 1,
  449. Credentials: map[string]any{"access_token": "test-token"},
  450. }
  451. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  452. require.Error(t, err)
  453. require.Equal(t, account.ID, repo.setErrorID)
  454. require.Contains(t, repo.setErrorMsg, "Authentication failed (401)")
  455. require.Zero(t, repo.rateLimitedID)
  456. require.Zero(t, repo.clearedErrorID)
  457. require.Nil(t, account.RateLimitResetAt)
  458. }
  459. func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testing.T) {
  460. gin.SetMode(gin.TestMode)
  461. ctx, _ := newTestContext()
  462. resp := newJSONResponse(http.StatusOK, "")
  463. resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n"))
  464. upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
  465. svc := &AccountTestService{
  466. httpUpstream: upstream,
  467. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  468. }
  469. account := &Account{
  470. ID: 95,
  471. Platform: PlatformOpenAI,
  472. Type: AccountTypeAPIKey,
  473. Concurrency: 1,
  474. Credentials: map[string]any{
  475. "api_key": "sk-test",
  476. "base_url": "https://compat-upstream.example/v1",
  477. },
  478. Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true},
  479. }
  480. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  481. require.NoError(t, err)
  482. require.Len(t, upstream.requests, 1)
  483. req := upstream.requests[0]
  484. require.Equal(t, "https://compat-upstream.example/v1/responses", req.URL.String())
  485. requireOpenAICodexProbeHeaders(t, req.Header)
  486. }
  487. func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) {
  488. gin.SetMode(gin.TestMode)
  489. ctx, recorder := newTestContext()
  490. upstreamBody := strings.Join([]string{
  491. `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"pong"},"finish_reason":null}]}`,
  492. "",
  493. `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  494. "",
  495. "data: [DONE]",
  496. "",
  497. }, "\n")
  498. upstream := &httpUpstreamRecorder{resp: &http.Response{
  499. StatusCode: http.StatusOK,
  500. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  501. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  502. }}
  503. svc := &AccountTestService{
  504. httpUpstream: upstream,
  505. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  506. }
  507. account := &Account{
  508. ID: 91,
  509. Platform: PlatformOpenAI,
  510. Type: AccountTypeAPIKey,
  511. Concurrency: 1,
  512. Credentials: map[string]any{
  513. "api_key": "sk-test",
  514. "base_url": "https://compat-upstream.example/v1",
  515. },
  516. Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
  517. }
  518. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "hello", "")
  519. require.NoError(t, err)
  520. require.NotNil(t, upstream.lastReq)
  521. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
  522. require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  523. require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("Authorization"))
  524. require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
  525. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
  526. require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
  527. require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
  528. require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  529. body := recorder.Body.String()
  530. require.Contains(t, body, "pong")
  531. require.Contains(t, body, "已通过 /v1/chat/completions 验证")
  532. require.Contains(t, body, `"success":true`)
  533. require.NotContains(t, body, "当前测试接口仅支持 Responses API 路径")
  534. }
  535. func TestAccountTestService_OpenAIChatCompletionsPathReturns4xx(t *testing.T) {
  536. gin.SetMode(gin.TestMode)
  537. ctx, recorder := newTestContext()
  538. upstream := &httpUpstreamRecorder{resp: newJSONResponse(http.StatusBadRequest, `{"error":{"message":"bad request"}}`)}
  539. svc := &AccountTestService{
  540. httpUpstream: upstream,
  541. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  542. }
  543. account := &Account{
  544. ID: 92,
  545. Platform: PlatformOpenAI,
  546. Type: AccountTypeAPIKey,
  547. Concurrency: 1,
  548. Credentials: map[string]any{
  549. "api_key": "sk-test",
  550. "base_url": "https://compat-upstream.example",
  551. },
  552. Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
  553. }
  554. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  555. require.Error(t, err)
  556. require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  557. require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) returned 400")
  558. require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
  559. require.NotContains(t, recorder.Body.String(), `"success":true`)
  560. }
  561. func TestAccountTestService_OpenAIChatCompletionsPathTimeout(t *testing.T) {
  562. gin.SetMode(gin.TestMode)
  563. ctx, recorder := newTestContext()
  564. upstream := &httpUpstreamRecorder{err: context.DeadlineExceeded}
  565. svc := &AccountTestService{
  566. httpUpstream: upstream,
  567. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  568. }
  569. account := &Account{
  570. ID: 93,
  571. Platform: PlatformOpenAI,
  572. Type: AccountTypeAPIKey,
  573. Concurrency: 1,
  574. Credentials: map[string]any{
  575. "api_key": "sk-test",
  576. "base_url": "https://compat-upstream.example",
  577. },
  578. Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
  579. }
  580. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  581. require.Error(t, err)
  582. require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  583. require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) request failed")
  584. require.Contains(t, err.Error(), context.DeadlineExceeded.Error())
  585. require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
  586. require.NotContains(t, recorder.Body.String(), `"success":true`)
  587. }
  588. func TestAccountTestService_OpenAIChatCompletionsPathRejectsNonJSONStream(t *testing.T) {
  589. gin.SetMode(gin.TestMode)
  590. ctx, recorder := newTestContext()
  591. upstream := &httpUpstreamRecorder{resp: &http.Response{
  592. StatusCode: http.StatusOK,
  593. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  594. Body: io.NopCloser(strings.NewReader("data: not-json\n\n")),
  595. }}
  596. svc := &AccountTestService{
  597. httpUpstream: upstream,
  598. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  599. }
  600. account := &Account{
  601. ID: 94,
  602. Platform: PlatformOpenAI,
  603. Type: AccountTypeAPIKey,
  604. Concurrency: 1,
  605. Credentials: map[string]any{
  606. "api_key": "sk-test",
  607. "base_url": "https://compat-upstream.example",
  608. },
  609. Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
  610. }
  611. err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
  612. require.Error(t, err)
  613. require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  614. require.Contains(t, err.Error(), "Invalid Chat Completions response from /v1/chat/completions")
  615. require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
  616. require.NotContains(t, recorder.Body.String(), `"success":true`)
  617. }