openai_cursor_warmup_pipeline_test.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "github.com/stretchr/testify/assert"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. "github.com/tidwall/sjson"
  9. )
  10. // TestCursorMixedShapeDetection covers the core invariant of the Cursor
  11. // compatibility fix in ForwardAsChatCompletions: when a client POSTs a
  12. // Responses-shaped body (has `input`, no `messages`) to /v1/chat/completions,
  13. // the request must be forwarded as-is with only the `model` field rewritten.
  14. // The raw `input` array (including Cursor's 80KB system prompt) must not be
  15. // discarded or reshaped.
  16. //
  17. // Context:
  18. //
  19. // Before the fix, the handler unmarshaled the body into ChatCompletionsRequest,
  20. // which has no Input field, silently dropping Cursor's input. The subsequent
  21. // conversion produced `input: null`, which Codex upstreams reject with
  22. // "Invalid type for 'input': expected a string, but got an object".
  23. func TestCursorMixedShapeDetection(t *testing.T) {
  24. // Representative Cursor cloud body — shape is what matters, content is
  25. // abridged. Notice: `input` is a Responses-API array, there is no
  26. // `messages` field at all, and `user`/`stream` are at the top level.
  27. cursorBody := []byte(`{
  28. "user": "85df22e7463ab6c2",
  29. "model": "gpt-5.4",
  30. "stream": true,
  31. "input": [
  32. {"role":"system","content":"You are GPT-5.4 running as a coding agent."},
  33. {"role":"user","content":"hello"}
  34. ],
  35. "service_tier": "auto",
  36. "reasoning": {"effort": "high"}
  37. }`)
  38. // --- Step 1: Shape detection (mirrors ForwardAsChatCompletions) ---
  39. hasMessages := gjson.GetBytes(cursorBody, "messages").Exists()
  40. hasInput := gjson.GetBytes(cursorBody, "input").Exists()
  41. isResponsesShape := !hasMessages && hasInput
  42. require.True(t, isResponsesShape,
  43. "Cursor body must be detected as Responses-shape (has input, no messages)")
  44. // --- Step 2: Model rewrite (mirrors the sjson.SetBytes branch) ---
  45. const upstreamModel = "gpt-5.1-codex"
  46. rewritten, err := sjson.SetBytes(cursorBody, "model", upstreamModel)
  47. require.NoError(t, err)
  48. // --- Step 3: Invariants of the rewritten body ---
  49. // 3a. model must be rewritten to the upstream target.
  50. assert.Equal(t, upstreamModel, gjson.GetBytes(rewritten, "model").String())
  51. // 3b. input array must be preserved verbatim — no reshaping, no nulling.
  52. inputResult := gjson.GetBytes(rewritten, "input")
  53. require.True(t, inputResult.Exists(), "input field must still exist after rewrite")
  54. require.True(t, inputResult.IsArray(), "input must still be an array (not null, not object)")
  55. items := inputResult.Array()
  56. require.Len(t, items, 2, "both input items must be preserved")
  57. assert.Equal(t, "system", items[0].Get("role").String())
  58. assert.Equal(t, "You are GPT-5.4 running as a coding agent.",
  59. items[0].Get("content").String())
  60. assert.Equal(t, "user", items[1].Get("role").String())
  61. assert.Equal(t, "hello", items[1].Get("content").String())
  62. // 3c. ALL other top-level fields must survive intact.
  63. assert.Equal(t, "85df22e7463ab6c2", gjson.GetBytes(rewritten, "user").String())
  64. assert.Equal(t, true, gjson.GetBytes(rewritten, "stream").Bool())
  65. assert.Equal(t, "auto", gjson.GetBytes(rewritten, "service_tier").String())
  66. assert.Equal(t, "high", gjson.GetBytes(rewritten, "reasoning.effort").String())
  67. // 3d. Final upstream body must NOT contain the old "input":null pattern.
  68. assert.NotContains(t, string(rewritten), `"input":null`,
  69. "rewritten body must not collapse input to null")
  70. }
  71. // TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected guards that
  72. // the shape detection does NOT misfire on a standard Chat Completions request
  73. // (one that has a `messages` array). Such requests must fall through to the
  74. // existing ChatCompletionsToResponses conversion path.
  75. func TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected(t *testing.T) {
  76. body := []byte(`{
  77. "model": "gpt-4o",
  78. "messages": [{"role":"user","content":"hi"}],
  79. "stream": true
  80. }`)
  81. hasMessages := gjson.GetBytes(body, "messages").Exists()
  82. hasInput := gjson.GetBytes(body, "input").Exists()
  83. isResponsesShape := !hasMessages && hasInput
  84. assert.False(t, isResponsesShape,
  85. "standard Chat Completions body must NOT be detected as Responses-shape")
  86. }
  87. // TestCursorMixedShapeDetection_BothFieldsPrefersMessages guards the
  88. // ambiguous case where a client sends both `messages` and `input`. We fall
  89. // through to the normal conversion path (messages wins), since mixing the
  90. // two is almost certainly a client bug and messages is the documented
  91. // Chat Completions contract.
  92. func TestCursorMixedShapeDetection_BothFieldsPrefersMessages(t *testing.T) {
  93. body := []byte(`{
  94. "model": "gpt-4o",
  95. "messages": [{"role":"user","content":"hi"}],
  96. "input": [{"role":"user","content":"other"}]
  97. }`)
  98. hasMessages := gjson.GetBytes(body, "messages").Exists()
  99. hasInput := gjson.GetBytes(body, "input").Exists()
  100. isResponsesShape := !hasMessages && hasInput
  101. assert.False(t, isResponsesShape,
  102. "when both messages and input are present, must not take the Cursor shortcut")
  103. }
  104. // TestCursorMixedShapeDetection_EmptyBody ensures a body with neither
  105. // messages nor input is NOT taken as Cursor-shape (would hit the normal
  106. // conversion and fail on its own with a clearer error).
  107. func TestCursorMixedShapeDetection_EmptyBody(t *testing.T) {
  108. body := []byte(`{"model":"gpt-5.4","stream":true}`)
  109. hasMessages := gjson.GetBytes(body, "messages").Exists()
  110. hasInput := gjson.GetBytes(body, "input").Exists()
  111. isResponsesShape := !hasMessages && hasInput
  112. assert.False(t, isResponsesShape,
  113. "body with neither messages nor input must not be taken as Cursor shape")
  114. }
  115. // TestCursorMixedShape_JSONRoundtrip ensures the rewritten body is still
  116. // valid JSON and parseable back into a map without surprises — catches
  117. // any encoding drift from sjson.
  118. func TestCursorMixedShape_JSONRoundtrip(t *testing.T) {
  119. cursorBody := []byte(`{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"hi"}]}`)
  120. rewritten, err := sjson.SetBytes(cursorBody, "model", "gpt-5.1-codex")
  121. require.NoError(t, err)
  122. var parsed map[string]any
  123. require.NoError(t, json.Unmarshal(rewritten, &parsed))
  124. assert.Equal(t, "gpt-5.1-codex", parsed["model"])
  125. assert.Equal(t, true, parsed["stream"])
  126. inputArr, ok := parsed["input"].([]any)
  127. require.True(t, ok, "input must decode to a Go []any after round-trip")
  128. require.Len(t, inputArr, 1)
  129. }
  130. // TestCursorMixedShape_StripsUnsupportedFields mirrors the strip loop in
  131. // ForwardAsChatCompletions (isResponsesShape branch). Cursor cloud sends
  132. // prompt_cache_retention, safety_identifier, metadata and stream_options
  133. // as top-level Responses API parameters, which Codex upstreams reject with
  134. // "Unsupported parameter: ...". The fix must remove them from the raw body
  135. // before it is forwarded, for BOTH OAuth and API Key account types.
  136. func TestCursorMixedShape_StripsUnsupportedFields(t *testing.T) {
  137. cursorBody := []byte(`{
  138. "model": "gpt-5.4",
  139. "stream": true,
  140. "prompt_cache_retention": "24h",
  141. "safety_identifier": "cursor-user-xyz",
  142. "metadata": {"trace_id":"abc","caller":"cursor"},
  143. "stream_options": {"include_usage": true},
  144. "input": [{"role":"user","content":"hi"}]
  145. }`)
  146. // Sanity: the test fixture contains every field the production code strips.
  147. for _, field := range cursorResponsesUnsupportedFields {
  148. require.True(t, gjson.GetBytes(cursorBody, field).Exists(),
  149. "test fixture must contain %s", field)
  150. }
  151. // Run the exact same loop as the production code.
  152. result := cursorBody
  153. for _, field := range cursorResponsesUnsupportedFields {
  154. if stripped, err := sjson.DeleteBytes(result, field); err == nil {
  155. result = stripped
  156. }
  157. }
  158. // All unsupported fields must be gone.
  159. for _, field := range cursorResponsesUnsupportedFields {
  160. assert.False(t, gjson.GetBytes(result, field).Exists(),
  161. "%s must be stripped", field)
  162. }
  163. // Everything else must survive intact.
  164. assert.Equal(t, "gpt-5.4", gjson.GetBytes(result, "model").String())
  165. assert.Equal(t, true, gjson.GetBytes(result, "stream").Bool())
  166. assert.True(t, gjson.GetBytes(result, "input").IsArray())
  167. assert.Equal(t, "user", gjson.GetBytes(result, "input.0.role").String())
  168. }