requestmodel_test.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package requestmodel
  2. import (
  3. "bytes"
  4. "mime/multipart"
  5. "net/http"
  6. "net/http/httptest"
  7. "strconv"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
  10. )
  11. func TestFromBodyJSONModel(t *testing.T) {
  12. if got := FromBody("application/json", []byte(`{"model":"gpt-5.4"}`)); got != "gpt-5.4" {
  13. t.Fatalf("got %q", got)
  14. }
  15. }
  16. func TestFromBodyJSONSessionModel(t *testing.T) {
  17. if got := FromBody("application/json", []byte(`{"session":{"model":"gpt-live"},"sdp":"v=0"}`)); got != "gpt-live" {
  18. t.Fatalf("got %q", got)
  19. }
  20. }
  21. func TestFromBodyJSONModelWinsOverSession(t *testing.T) {
  22. body := []byte(`{"model":"top-level","session":{"model":"nested"}}`)
  23. if got := FromBody("application/json", body); got != "top-level" {
  24. t.Fatalf("got %q", got)
  25. }
  26. }
  27. func TestFromBodyJSONBlankModelFallsThrough(t *testing.T) {
  28. if got := FromBody("application/json", []byte(`{"model":" "}`)); got != "" {
  29. t.Fatalf("got %q", got)
  30. }
  31. }
  32. func TestFromBodyMultipartModelField(t *testing.T) {
  33. var body bytes.Buffer
  34. writer := multipart.NewWriter(&body)
  35. _ = writer.WriteField("prompt", "draw")
  36. _ = writer.WriteField("model", "gpt-image-1")
  37. _ = writer.Close()
  38. if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" {
  39. t.Fatalf("got %q", got)
  40. }
  41. }
  42. func TestFromBodyMultipartSessionField(t *testing.T) {
  43. var body bytes.Buffer
  44. writer := multipart.NewWriter(&body)
  45. _ = writer.WriteField("sdp", "v=0")
  46. _ = writer.WriteField("session", `{"model":"live-alias"}`)
  47. _ = writer.WriteField("media", "file-part") // 字段名不匹配,应被跳过
  48. _ = writer.Close()
  49. if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "live-alias" {
  50. t.Fatalf("got %q", got)
  51. }
  52. }
  53. func TestFromBodySkipsFilePartsBeforeModelField(t *testing.T) {
  54. var body bytes.Buffer
  55. writer := multipart.NewWriter(&body)
  56. part, err := writer.CreateFormFile("file", "input.png")
  57. if err != nil {
  58. t.Fatalf("create form file: %v", err)
  59. }
  60. _, _ = part.Write(bytes.Repeat([]byte{0}, 4096))
  61. _ = writer.WriteField("model", "gpt-image-1")
  62. _ = writer.Close()
  63. if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" {
  64. t.Fatalf("got %q", got)
  65. }
  66. }
  67. func TestFromBodyNoModelReturnsEmpty(t *testing.T) {
  68. if got := FromBody("application/json", []byte(`{"input":"hi"}`)); got != "" {
  69. t.Fatalf("got %q", got)
  70. }
  71. if got := FromBody("text/plain", []byte("plain")); got != "" {
  72. t.Fatalf("got %q", got)
  73. }
  74. }
  75. func TestResetRequestBodyAllowsRereadWithoutCopy(t *testing.T) {
  76. body := []byte(`{"model":"gpt-5.4","input":"hi"}`)
  77. req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  78. ResetRequestBody(req, body)
  79. got, err := httputil.ReadRequestBodyWithPrealloc(req)
  80. if err != nil {
  81. t.Fatalf("reread: %v", err)
  82. }
  83. if &got[0] != &body[0] {
  84. t.Fatalf("expected zero-copy reread of the same slice")
  85. }
  86. if req.ContentLength != int64(len(body)) {
  87. t.Fatalf("content length mismatch: %d", req.ContentLength)
  88. }
  89. if req.Header.Get("Content-Length") != strconv.Itoa(len(body)) {
  90. t.Fatalf("content-length header mismatch: %q", req.Header.Get("Content-Length"))
  91. }
  92. }
  93. func TestResetRequestBodyNilRequestIsNoop(t *testing.T) {
  94. ResetRequestBody(nil, []byte("x"))
  95. }
  96. func TestFromBodyForRouteLivePrefersSessionModel(t *testing.T) {
  97. body := []byte(`{"model":"top-level","session":{"model":"session-model"},"sdp":"v=0"}`)
  98. if got := FromBodyForRoute("/v1/live", "application/json", body); got != "session-model" {
  99. t.Fatalf("live route should prefer session.model, got %q", got)
  100. }
  101. if got := FromBodyForRoute("/backend-api/codex/realtime/calls", "application/json", body); got != "session-model" {
  102. t.Fatalf("codex realtime route should prefer session.model, got %q", got)
  103. }
  104. if got := FromBodyForRoute("/live", "application/json", body); got != "session-model" {
  105. t.Fatalf("root live alias should prefer session.model, got %q", got)
  106. }
  107. }
  108. func TestFromBodyForRouteNonLivePrefersTopLevelModel(t *testing.T) {
  109. body := []byte(`{"model":"top-level","session":{"model":"session-model"}}`)
  110. if got := FromBodyForRoute("/v1/messages", "application/json", body); got != "top-level" {
  111. t.Fatalf("non-live route should prefer top-level model, got %q", got)
  112. }
  113. if got := FromBodyForRoute("/v1/responses", "application/json", body); got != "top-level" {
  114. t.Fatalf("responses route should prefer top-level model, got %q", got)
  115. }
  116. }
  117. func TestFromBodyForRouteFallsBackWhenPreferredFieldMissing(t *testing.T) {
  118. if got := FromBodyForRoute("/v1/live", "application/json", []byte(`{"model":"top-level"}`)); got != "top-level" {
  119. t.Fatalf("live without session.model should fall back to model, got %q", got)
  120. }
  121. if got := FromBodyForRoute("/v1/messages", "application/json", []byte(`{"session":{"model":"session-model"}}`)); got != "session-model" {
  122. t.Fatalf("non-live without model should fall back to session.model, got %q", got)
  123. }
  124. }
  125. func TestFromBodyForRouteMultipartSessionFirstForLive(t *testing.T) {
  126. var body bytes.Buffer
  127. writer := multipart.NewWriter(&body)
  128. _ = writer.WriteField("model", "top-level")
  129. _ = writer.WriteField("session", `{"model":"session-model"}`)
  130. _ = writer.Close()
  131. contentType := writer.FormDataContentType()
  132. if got := FromBodyForRoute("/v1/live", contentType, body.Bytes()); got != "session-model" {
  133. t.Fatalf("live multipart should prefer session field, got %q", got)
  134. }
  135. if got := FromBodyForRoute("/v1/messages", contentType, body.Bytes()); got != "top-level" {
  136. t.Fatalf("non-live multipart should prefer model field, got %q", got)
  137. }
  138. }
  139. func TestIsLiveRequestRoute(t *testing.T) {
  140. for _, path := range []string{"/v1/live", "/live", "/backend-api/codex/realtime/calls"} {
  141. if !IsLiveRequestRoute(path) {
  142. t.Fatalf("%s should be a live route", path)
  143. }
  144. }
  145. for _, path := range []string{"/v1/messages", "/v1/responses", "/realtime", ""} {
  146. if IsLiveRequestRoute(path) {
  147. t.Fatalf("%s should not be a live route", path)
  148. }
  149. }
  150. }
  151. // gjson 的部分扫描会在 multipart 字节流里误匹配 session JSON 内的 model;
  152. // 声明为 multipart 的请求体必须走 multipart 解析。
  153. func TestFromBodyMultipartContentTypeSkipsJSONScan(t *testing.T) {
  154. var body bytes.Buffer
  155. writer := multipart.NewWriter(&body)
  156. _ = writer.WriteField("model", "top-level")
  157. _ = writer.WriteField("session", `{"model":"session-model"}`)
  158. _ = writer.Close()
  159. if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "top-level" {
  160. t.Fatalf("multipart body should use the model field, got %q", got)
  161. }
  162. if got := FromBodyForRoute("/v1/live", writer.FormDataContentType(), body.Bytes()); got != "session-model" {
  163. t.Fatalf("live multipart should use the session field, got %q", got)
  164. }
  165. }
  166. func TestFromBodyCandidatesIncludesCaseVariantsAndDuplicates(t *testing.T) {
  167. // encoding/json 绑定对键名大小写不敏感、重复键取末值;gjson 取首个。
  168. // 候选集必须包含全部出现。
  169. got := FromBodyCandidates("", "application/json", []byte(`{"Model":"caps-model"}`))
  170. if len(got) != 1 || got[0] != "caps-model" {
  171. t.Fatalf("case-variant key must be a candidate, got %#v", got)
  172. }
  173. got = FromBodyCandidates("", "application/json", []byte(`{"model":"first","model":"last"}`))
  174. if len(got) != 2 || got[0] != "first" || got[1] != "last" {
  175. t.Fatalf("duplicate keys must both be candidates, got %#v", got)
  176. }
  177. }
  178. func TestFromBodyCandidatesSessionVariants(t *testing.T) {
  179. body := []byte(`{"Session":{"Model":"s-caps"},"session":{"model":"s-lower"},"model":"top"}`)
  180. // 非 Live 入口:顶层候选。
  181. got := FromBodyCandidates("", "application/json", body)
  182. if len(got) != 1 || got[0] != "top" {
  183. t.Fatalf("non-live should use top-level candidates, got %#v", got)
  184. }
  185. // Live 入口:session 候选(含大小写变体与重复)。
  186. got = FromBodyCandidates("/v1/live", "application/json", body)
  187. if len(got) != 2 || got[0] != "s-caps" || got[1] != "s-lower" {
  188. t.Fatalf("live should collect all session candidates, got %#v", got)
  189. }
  190. }
  191. func TestFromBodyCandidatesMultipartDuplicateFields(t *testing.T) {
  192. var body bytes.Buffer
  193. writer := multipart.NewWriter(&body)
  194. _ = writer.WriteField("model", "gpt-image-1")
  195. _ = writer.WriteField("model", "gpt-image-1.5")
  196. _ = writer.WriteField("session", `{"model":"live-a"}`)
  197. _ = writer.WriteField("session", `{"model":"live-b"}`)
  198. _ = writer.Close()
  199. models, sessions := multipartModelCandidates(writer.FormDataContentType(), body.Bytes())
  200. if len(models) != 2 || models[0] != "gpt-image-1" || models[1] != "gpt-image-1.5" {
  201. t.Fatalf("duplicate model fields must all be candidates, got %#v", models)
  202. }
  203. if len(sessions) != 2 {
  204. t.Fatalf("duplicate session fields must all be captured, got %#v", sessions)
  205. }
  206. got := FromBodyCandidates("", writer.FormDataContentType(), body.Bytes())
  207. if len(got) != 2 || got[0] != "gpt-image-1" || got[1] != "gpt-image-1.5" {
  208. t.Fatalf("non-live multipart candidates mismatch, got %#v", got)
  209. }
  210. got = FromBodyCandidates("/v1/live", writer.FormDataContentType(), body.Bytes())
  211. if len(got) != 2 || got[0] != "live-a" || got[1] != "live-b" {
  212. t.Fatalf("live multipart session candidates mismatch, got %#v", got)
  213. }
  214. }
  215. func TestFromBodyForRouteReturnsFirstCandidate(t *testing.T) {
  216. got := FromBodyForRoute("", "application/json", []byte(`{"model":"first","model":"last"}`))
  217. if got != "first" {
  218. t.Fatalf("dispatch helper keeps gjson-first semantics, got %q", got)
  219. }
  220. }