openai_setup_token_compat_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestIsOpenAIOAuthLike(t *testing.T) {
  17. tests := []struct {
  18. name string
  19. account *Account
  20. want bool
  21. codex bool
  22. }{
  23. {name: "openai_oauth", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, want: true, codex: true},
  24. {name: "openai_setup_token", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, want: true, codex: true},
  25. {name: "openai_api_key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: false, codex: false},
  26. {name: "anthropic_setup_token", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, want: false, codex: false},
  27. {name: "grok_setup_token", account: &Account{Platform: PlatformGrok, Type: AccountTypeSetupToken}, want: false, codex: false},
  28. {name: "nil", account: nil, want: false, codex: false},
  29. }
  30. for _, tt := range tests {
  31. t.Run(tt.name, func(t *testing.T) {
  32. require.Equal(t, tt.want, tt.account.IsOpenAIOAuthLike())
  33. require.Equal(t, tt.codex, tt.account.UsesOpenAICodexProtocol())
  34. })
  35. }
  36. }
  37. func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) {
  38. svc := &OpenAIGatewayService{openAITokenProvider: &OpenAITokenProvider{}}
  39. account := &Account{
  40. Platform: PlatformOpenAI,
  41. Type: AccountTypeSetupToken,
  42. Credentials: map[string]any{"access_token": "setup-token-value"},
  43. }
  44. token, tokenType, err := svc.GetAccessToken(context.Background(), account)
  45. require.NoError(t, err)
  46. require.Equal(t, "setup-token-value", token)
  47. require.Equal(t, "oauth", tokenType)
  48. delete(account.Credentials, "access_token")
  49. _, _, err = svc.GetAccessToken(context.Background(), account)
  50. require.EqualError(t, err, "access_token not found in credentials")
  51. for _, platform := range []string{PlatformAnthropic, PlatformGrok} {
  52. foreign := &Account{
  53. Platform: platform,
  54. Type: AccountTypeSetupToken,
  55. Credentials: map[string]any{"access_token": "foreign-token"},
  56. }
  57. _, _, err = svc.GetAccessToken(context.Background(), foreign)
  58. require.EqualError(t, err, "unsupported account type: setup-token")
  59. }
  60. }
  61. func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
  62. gin.SetMode(gin.TestMode)
  63. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  64. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  65. upstream := &httpUpstreamRecorder{resp: &http.Response{
  66. StatusCode: http.StatusTooManyRequests,
  67. Header: http.Header{"Content-Type": []string{"application/json"}},
  68. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
  69. }}
  70. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  71. account := &Account{
  72. ID: 73,
  73. Platform: PlatformOpenAI,
  74. Type: AccountTypeSetupToken,
  75. Credentials: map[string]any{"access_token": "setup-token"},
  76. }
  77. parsed := &OpenAIImagesRequest{
  78. Endpoint: openAIImagesGenerationsEndpoint,
  79. Model: "gpt-image-2",
  80. Prompt: "draw a square",
  81. N: 1,
  82. ResponseFormat: "b64_json",
  83. }
  84. result, err := svc.ForwardImages(context.Background(), c, account, nil, parsed, "")
  85. require.Nil(t, result)
  86. var failoverErr *UpstreamFailoverError
  87. require.ErrorAs(t, err, &failoverErr)
  88. require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
  89. require.True(t, failoverErr.RetryableOnSameAccount)
  90. require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
  91. require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses")
  92. }
  93. func TestOpenAISetupTokenWSCompatibility(t *testing.T) {
  94. gin.SetMode(gin.TestMode)
  95. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  96. c.Request = httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{}`))
  97. c.Request.Header.Set("session_id", "session-one")
  98. c.Set("api_key", &APIKey{ID: 17})
  99. account := &Account{
  100. Platform: PlatformOpenAI,
  101. Type: AccountTypeSetupToken,
  102. Credentials: map[string]any{
  103. "access_token": "setup-token-value",
  104. "chatgpt_account_id": "chatgpt-setup",
  105. },
  106. }
  107. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  108. wsURL, err := svc.buildOpenAIResponsesWSURL(account)
  109. require.NoError(t, err)
  110. require.Equal(t, "wss://chatgpt.com/backend-api/codex/responses", wsURL)
  111. foreignURL, err := svc.buildOpenAIResponsesWSURL(&Account{Platform: PlatformGrok, Type: AccountTypeSetupToken})
  112. require.NoError(t, err)
  113. require.Equal(t, "wss://api.openai.com/v1/responses", foreignURL)
  114. headers, session, err := svc.buildOpenAIWSHeaders(
  115. context.Background(), c, account, "setup-token-value",
  116. OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
  117. true, "", "", "", "gpt-5.1-codex", "",
  118. )
  119. require.NoError(t, err)
  120. require.Equal(t, "Bearer setup-token-value", headers.Get("authorization"))
  121. require.Equal(t, "chatgpt-setup", headers.Get("chatgpt-account-id"))
  122. require.NotEmpty(t, headers.Get("originator"))
  123. require.Equal(t, "session-one", session.SessionID)
  124. require.NotEqual(t, session.SessionID, headers.Get("session_id"))
  125. payload := svc.buildOpenAIWSCreatePayload(map[string]any{"store": true}, account)
  126. require.Equal(t, false, payload["store"])
  127. }
  128. func TestOpenAISetupTokenChatCompletionsUsesCodexTransform(t *testing.T) {
  129. gin.SetMode(gin.TestMode)
  130. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"setup instructions"},{"role":"user","content":"hello"}],"stream":false}`)
  131. rec := httptest.NewRecorder()
  132. c, _ := gin.CreateTestContext(rec)
  133. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  134. c.Request.Header.Set("Content-Type", "application/json")
  135. upstream := &httpUpstreamRecorder{resp: &http.Response{
  136. StatusCode: http.StatusBadRequest,
  137. Header: http.Header{"Content-Type": []string{"application/json"}},
  138. Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after request capture"}}`)),
  139. }}
  140. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  141. account := openAISetupTokenCompatAccount(71)
  142. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
  143. require.Error(t, err)
  144. require.Nil(t, result)
  145. require.NotNil(t, upstream.lastReq)
  146. require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
  147. require.Equal(t, "Bearer setup-token-value", upstream.lastReq.Header.Get("Authorization"))
  148. require.Equal(t, "chatgpt-setup", upstream.lastReq.Header.Get("chatgpt-account-id"))
  149. require.NotEmpty(t, upstream.lastReq.Header.Get("originator"))
  150. require.Equal(t, "setup instructions", gjson.GetBytes(upstream.lastBody, "instructions").String())
  151. require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
  152. require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  153. require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
  154. }
  155. func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) {
  156. gin.SetMode(gin.TestMode)
  157. firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4")
  158. firstResp.Header.Set("x-codex-turn-state", "turn_state_setup")
  159. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  160. firstResp,
  161. openAICompatSSECompletedResponse("resp_setup_second", "gpt-5.4"),
  162. }}
  163. svc := &OpenAIGatewayService{
  164. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  165. httpUpstream: upstream,
  166. }
  167. account := openAISetupTokenCompatAccount(72)
  168. messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
  169. for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
  170. messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
  171. }
  172. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
  173. firstRec := httptest.NewRecorder()
  174. firstCtx, _ := gin.CreateTestContext(firstRec)
  175. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  176. firstCtx.Request.Header.Set("Content-Type", "application/json")
  177. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4")
  178. require.NoError(t, err)
  179. require.NotNil(t, firstResult)
  180. require.True(t, isOpenAICompatMessagesBridgeContext(firstCtx))
  181. require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.bodies[0], "input.#").Int())
  182. require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[0], "input.0.role").String())
  183. require.Contains(t, gjson.GetBytes(upstream.bodies[0], "input.0.content.0.text").String(), openAICompatClaudeCodeTodoGuardMarker)
  184. require.Equal(t, "message-00", gjson.GetBytes(upstream.bodies[0], "input.1.content.0.text").String())
  185. require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
  186. require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String())
  187. require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization"))
  188. require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id"))
  189. requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex-tui")
  190. require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
  191. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`)
  192. secondRec := httptest.NewRecorder()
  193. secondCtx, _ := gin.CreateTestContext(secondRec)
  194. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  195. secondCtx.Request.Header.Set("Content-Type", "application/json")
  196. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4")
  197. require.NoError(t, err)
  198. require.NotNil(t, secondResult)
  199. require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx))
  200. require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state"))
  201. require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
  202. require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
  203. requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui")
  204. }
  205. func openAISetupTokenCompatAccount(id int64) *Account {
  206. return &Account{
  207. ID: id,
  208. Name: "openai-setup-token",
  209. Platform: PlatformOpenAI,
  210. Type: AccountTypeSetupToken,
  211. Concurrency: 1,
  212. Credentials: map[string]any{
  213. "access_token": "setup-token-value",
  214. "chatgpt_account_id": "chatgpt-setup",
  215. },
  216. }
  217. }