openai_live_test.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  13. coderws "github.com/coder/websocket"
  14. "github.com/stretchr/testify/require"
  15. )
  16. type liveHTTPUpstreamStub struct {
  17. request *http.Request
  18. body []byte
  19. }
  20. type liveAttestationStub struct {
  21. header string
  22. err error
  23. }
  24. func (s liveAttestationStub) Check(context.Context) error {
  25. return s.err
  26. }
  27. func (s liveAttestationStub) Generate(context.Context) (string, error) {
  28. return s.header, s.err
  29. }
  30. func (s *liveHTTPUpstreamStub) Do(
  31. request *http.Request,
  32. _ string,
  33. _ int64,
  34. _ int,
  35. ) (*http.Response, error) {
  36. s.request = request
  37. body, err := io.ReadAll(request.Body)
  38. if err != nil {
  39. return nil, err
  40. }
  41. s.body = body
  42. return &http.Response{
  43. StatusCode: http.StatusOK,
  44. Header: http.Header{
  45. "Location": {"/backend-api/codex/call_test"},
  46. },
  47. Body: io.NopCloser(strings.NewReader("v=0\r\n")),
  48. }, nil
  49. }
  50. func (s *liveHTTPUpstreamStub) DoWithTLS(
  51. request *http.Request,
  52. proxyURL string,
  53. accountID int64,
  54. accountConcurrency int,
  55. _ *tlsfingerprint.Profile,
  56. ) (*http.Response, error) {
  57. return s.Do(request, proxyURL, accountID, accountConcurrency)
  58. }
  59. func TestLiveCapabilityOnlyAllowsOpenAIOAuth(t *testing.T) {
  60. require.True(t, (&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive))
  61. require.False(t, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive))
  62. require.False(t, (&Account{Platform: PlatformGrok, Type: AccountTypeOAuth}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive))
  63. require.False(t, (&Account{
  64. Platform: PlatformOpenAI,
  65. Type: AccountTypeOAuth,
  66. Credentials: map[string]any{
  67. openAIAuthModeCredentialKey: OpenAIAuthModePersonalAccessToken,
  68. },
  69. }).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive))
  70. require.False(t, (&Account{
  71. Platform: PlatformOpenAI,
  72. Type: AccountTypeOAuth,
  73. Credentials: map[string]any{
  74. openAIAuthModeCredentialKey: OpenAIAuthModeAgentIdentity,
  75. },
  76. }).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive))
  77. }
  78. func TestValidateLiveCallRequestDoesNotRequireDelegation(t *testing.T) {
  79. request := &LiveCallRequest{
  80. SDP: "v=0\r\n",
  81. Session: json.RawMessage(`{"model":"gpt-live-test","instructions":"hello"}`),
  82. }
  83. require.NoError(t, ValidateLiveCallRequest(request))
  84. require.NotContains(t, string(request.Session), "delegation")
  85. }
  86. func TestCreateUpstreamLiveCallPreservesSession(t *testing.T) {
  87. upstream := &liveHTTPUpstreamStub{}
  88. service := &OpenAIGatewayService{
  89. cfg: &config.Config{},
  90. httpUpstream: upstream,
  91. }
  92. account := &Account{
  93. ID: 7,
  94. Platform: PlatformOpenAI,
  95. Type: AccountTypeOAuth,
  96. Concurrency: 2,
  97. Credentials: map[string]any{
  98. "access_token": "test-access-token",
  99. "chatgpt_account_id": "acct_test",
  100. },
  101. }
  102. session := json.RawMessage(`{
  103. "model":"gpt-live-test",
  104. "delegation":{"type":"client"},
  105. "custom":{"keep":true}
  106. }`)
  107. created, err := service.createUpstreamLiveCall(context.Background(), account, &LiveCallRequest{
  108. SDP: "v=offer\r\n",
  109. Session: session,
  110. }, `{"v":1,"s":0,"t":"v1.test"}`)
  111. require.NoError(t, err)
  112. require.Equal(t, "call_test", created.CallID)
  113. require.Equal(t, []byte("v=0\r\n"), created.SDP)
  114. var forwarded struct {
  115. SDP string `json:"sdp"`
  116. Session json.RawMessage `json:"session"`
  117. }
  118. require.NoError(t, json.Unmarshal(upstream.body, &forwarded))
  119. require.Equal(t, "v=offer\r\n", forwarded.SDP)
  120. require.JSONEq(t, string(session), string(forwarded.Session))
  121. require.Equal(t, "Bearer test-access-token", upstream.request.Header.Get("Authorization"))
  122. require.Equal(t, "acct_test", upstream.request.Header.Get("Chatgpt-Account-Id"))
  123. require.Equal(t, "quicksilver=v2", upstream.request.Header.Get("OpenAI-Alpha"))
  124. require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, upstream.request.Header.Get(liveAttestationHeader))
  125. require.NotEmpty(t, upstream.request.Header.Get("Session-Id"))
  126. require.NotEmpty(t, upstream.request.Header.Get("Thread-Id"))
  127. require.Empty(t, upstream.request.Header.Get("OpenAI-Beta"))
  128. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.request.Context()))
  129. require.True(t, HTTPUpstreamRedirectsDisabled(upstream.request.Context()))
  130. }
  131. func TestLiveAttestationCipherRoundTripAndRejectsOtherInstanceKey(t *testing.T) {
  132. first := newLiveAttestationCipher(&config.Config{
  133. JWT: config.JWTConfig{Secret: "first-live-secret"},
  134. })
  135. second := newLiveAttestationCipher(&config.Config{
  136. JWT: config.JWTConfig{Secret: "second-live-secret"},
  137. })
  138. require.NotNil(t, first)
  139. require.NotNil(t, second)
  140. ciphertext, err := first.Encrypt(`{"v":1,"s":0,"t":"v1.opaque"}`)
  141. require.NoError(t, err)
  142. require.NotContains(t, ciphertext, "opaque")
  143. plaintext, err := first.Decrypt(ciphertext)
  144. require.NoError(t, err)
  145. require.Equal(t, `{"v":1,"s":0,"t":"v1.opaque"}`, plaintext)
  146. _, err = second.Decrypt(ciphertext)
  147. require.Error(t, err)
  148. }
  149. func TestPrepareLiveAttestationEncryptsHeaderAndReturnsExplicitProviderError(t *testing.T) {
  150. cipher := newLiveAttestationCipher(&config.Config{
  151. JWT: config.JWTConfig{Secret: "live-attestation-test-secret"},
  152. })
  153. service := &OpenAIGatewayService{
  154. liveAttestation: liveAttestationStub{header: `{"v":1,"s":0,"t":"v1.test"}`},
  155. liveAttestationCipher: cipher,
  156. }
  157. header, ciphertext, err := service.prepareLiveAttestation(context.Background())
  158. require.NoError(t, err)
  159. require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, header)
  160. require.NotContains(t, ciphertext, "v1.test")
  161. decrypted, err := cipher.Decrypt(ciphertext)
  162. require.NoError(t, err)
  163. require.Equal(t, header, decrypted)
  164. service.liveAttestation = liveAttestationStub{err: errors.New("macOS app missing")}
  165. _, _, err = service.prepareLiveAttestation(context.Background())
  166. var unavailable *LiveAttestationUnavailableError
  167. require.ErrorAs(t, err, &unavailable)
  168. require.Contains(t, unavailable.Error(), "macOS app missing")
  169. }
  170. func TestLiveMaxSessionDurationDefaultsAndOverrides(t *testing.T) {
  171. require.Equal(t, defaultLiveMaxSessionDuration, (&OpenAIGatewayService{}).liveMaxSessionDuration())
  172. require.Equal(
  173. t,
  174. 90*time.Second,
  175. (&OpenAIGatewayService{cfg: &config.Config{
  176. Gateway: config.GatewayConfig{
  177. Live: config.GatewayLiveConfig{MaxSessionDurationSeconds: 90},
  178. },
  179. }}).liveMaxSessionDuration(),
  180. )
  181. }
  182. func TestLiveSidebandNormalCloseEndsCall(t *testing.T) {
  183. normalClose := coderws.CloseError{Code: coderws.StatusNormalClosure}
  184. require.ErrorIs(t, liveSidebandReadError(normalClose), ErrLiveCallNotFound)
  185. abnormalClose := coderws.CloseError{Code: coderws.StatusInternalError}
  186. require.Equal(t, abnormalClose, liveSidebandReadError(abnormalClose))
  187. }
  188. func TestLiveCreateFailoverUsesExistingOpenAIPolicy(t *testing.T) {
  189. service := &OpenAIGatewayService{}
  190. account := newOpenAIUpstreamErrorTestAccount()
  191. require.False(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{
  192. StatusCode: http.StatusBadRequest,
  193. ResponseBody: []byte(`{"error":{"message":"invalid session"}}`),
  194. }))
  195. require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{
  196. StatusCode: http.StatusForbidden,
  197. }))
  198. require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{
  199. StatusCode: http.StatusBadGateway,
  200. }))
  201. require.True(t, service.shouldFailoverLiveCreateError(account, errors.New("transport failed")))
  202. }
  203. func TestLiveCallIDFromLocation(t *testing.T) {
  204. callID, err := liveCallIDFromLocation("https://chatgpt.com/backend-api/codex/call_123?intent=quicksilver")
  205. require.NoError(t, err)
  206. require.Equal(t, "call_123", callID)
  207. callID, err = liveCallIDFromLocation("/backend-api/codex/call_456")
  208. require.NoError(t, err)
  209. require.Equal(t, "call_456", callID)
  210. }
  211. func TestRequestTypeLive(t *testing.T) {
  212. require.True(t, RequestTypeLive.IsValid())
  213. require.Equal(t, "live", RequestTypeLive.String())
  214. parsed, err := ParseUsageRequestType("live")
  215. require.NoError(t, err)
  216. require.Equal(t, RequestTypeLive, parsed)
  217. }