openai_images_b64_backfill_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  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. // 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。
  17. var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
  18. func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response {
  19. header := http.Header{}
  20. if contentType != "" {
  21. header.Set("Content-Type", contentType)
  22. }
  23. return &http.Response{
  24. StatusCode: status,
  25. Header: header,
  26. Body: io.NopCloser(bytes.NewReader(payload)),
  27. }
  28. }
  29. func b64BackfillAccount(enabled bool) *Account {
  30. account := &Account{
  31. ID: 7,
  32. Name: "openai-apikey",
  33. Platform: PlatformOpenAI,
  34. Type: AccountTypeAPIKey,
  35. Credentials: map[string]any{
  36. "api_key": "sk-test",
  37. "base_url": "https://relay.example.com/v1",
  38. },
  39. }
  40. if enabled {
  41. account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true}
  42. }
  43. return account
  44. }
  45. func TestImagesURLToB64JSONEnabled(t *testing.T) {
  46. require.False(t, ImagesURLToB64JSONEnabled(nil))
  47. require.False(t, ImagesURLToB64JSONEnabled(&Account{}))
  48. require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}}))
  49. require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}}))
  50. require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}}))
  51. }
  52. func TestBackfillOpenAIImagesB64JSON(t *testing.T) {
  53. wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes)
  54. tests := []struct {
  55. name string
  56. enabled bool
  57. parsed *OpenAIImagesRequest
  58. body string
  59. upstream *httpUpstreamRecorder
  60. wantB64 []string
  61. wantDownloads int
  62. }{
  63. {
  64. name: "开关关闭时原样返回",
  65. enabled: false,
  66. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
  67. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  68. wantB64: []string{""},
  69. wantDownloads: 0,
  70. },
  71. {
  72. name: "缺少 b64_json 且有 url 时下载回填并保留 url",
  73. enabled: true,
  74. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`,
  75. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  76. wantB64: []string{wantB64},
  77. wantDownloads: 1,
  78. },
  79. {
  80. name: "b64_json 为空串时同样回填",
  81. enabled: true,
  82. body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`,
  83. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  84. wantB64: []string{wantB64},
  85. wantDownloads: 1,
  86. },
  87. {
  88. name: "b64_json 已有值时不下载",
  89. enabled: true,
  90. body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`,
  91. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  92. wantB64: []string{"aW1n"},
  93. wantDownloads: 0,
  94. },
  95. {
  96. name: "多项混合时只回填缺失项",
  97. enabled: true,
  98. body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`,
  99. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  100. wantB64: []string{"aW1n", wantB64, ""},
  101. wantDownloads: 1,
  102. },
  103. {
  104. name: "data URL 直接取载荷不下载",
  105. enabled: true,
  106. body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`,
  107. upstream: &httpUpstreamRecorder{},
  108. wantB64: []string{wantB64},
  109. wantDownloads: 0,
  110. },
  111. {
  112. name: "客户端显式要求 url 时不回填",
  113. enabled: true,
  114. parsed: &OpenAIImagesRequest{ResponseFormat: "url"},
  115. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
  116. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  117. wantB64: []string{""},
  118. wantDownloads: 0,
  119. },
  120. {
  121. name: "下载非 2xx 时保留原样",
  122. enabled: true,
  123. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
  124. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))},
  125. wantB64: []string{""},
  126. wantDownloads: 1,
  127. },
  128. {
  129. name: "下载内容不是图片时保留原样",
  130. enabled: true,
  131. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
  132. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("<html>login required</html>"))},
  133. wantB64: []string{""},
  134. wantDownloads: 1,
  135. },
  136. {
  137. name: "响应头未声明图片类型但字节嗅探为图片时回填",
  138. enabled: true,
  139. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`,
  140. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)},
  141. wantB64: []string{wantB64},
  142. wantDownloads: 1,
  143. },
  144. {
  145. name: "响应头声明图片但字节不是图片时保留原样",
  146. enabled: true,
  147. body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
  148. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`))},
  149. wantB64: []string{""},
  150. wantDownloads: 1,
  151. },
  152. {
  153. name: "非 http(s) 协议的 url 不下载",
  154. enabled: true,
  155. body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`,
  156. upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
  157. wantB64: []string{""},
  158. wantDownloads: 0,
  159. },
  160. }
  161. for _, tt := range tests {
  162. t.Run(tt.name, func(t *testing.T) {
  163. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream}
  164. got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body))
  165. require.True(t, gjson.ValidBytes(got))
  166. items := gjson.GetBytes(got, "data").Array()
  167. require.Len(t, items, len(tt.wantB64))
  168. for i, want := range tt.wantB64 {
  169. require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i)
  170. }
  171. require.Len(t, tt.upstream.requests, tt.wantDownloads)
  172. // 除 b64_json 外的字段必须原样保留。
  173. original := gjson.Parse(tt.body)
  174. original.Get("data").ForEach(func(key, item gjson.Result) bool {
  175. item.ForEach(func(field, value gjson.Result) bool {
  176. if field.String() == "b64_json" {
  177. return true
  178. }
  179. require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw)
  180. return true
  181. })
  182. return true
  183. })
  184. require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw)
  185. })
  186. }
  187. }
  188. func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) {
  189. upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
  190. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  191. account := b64BackfillAccount(true)
  192. proxyID := int64(3)
  193. account.ProxyID = &proxyID
  194. account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890}
  195. body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`)
  196. got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body)
  197. require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String())
  198. require.Len(t, upstream.requests, 1)
  199. req := upstream.requests[0]
  200. require.Equal(t, http.MethodGet, req.Method)
  201. require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String())
  202. require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL)
  203. _, hasDeadline := req.Context().Deadline()
  204. require.True(t, hasDeadline)
  205. // 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。
  206. require.True(t, HTTPUpstreamPublicHostsOnly(req.Context()))
  207. require.False(t, HTTPUpstreamRedirectsDisabled(req.Context()))
  208. }
  209. func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) {
  210. upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
  211. cfg := &config.Config{}
  212. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  213. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  214. account := b64BackfillAccount(true)
  215. for _, rawURL := range []string{
  216. "http://localhost:8080/api/v1/admin/accounts",
  217. "http://Foo.LocalHost/a.png",
  218. "http://127.0.0.1:8080/a.png",
  219. "https://127.0.0.1/a.png",
  220. "http://[::1]:8080/a.png",
  221. "http://10.0.0.5/a.png",
  222. "http://172.16.0.9:9000/bucket/a.png",
  223. "http://192.168.1.10:9000/bucket/a.png",
  224. "http://169.254.169.254/latest/meta-data/",
  225. "http://0.0.0.0:8080/a.png",
  226. "http://[fe80::1]/a.png",
  227. "http://[::ffff:127.0.0.1]/a.png",
  228. } {
  229. body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}`
  230. got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body))
  231. require.Equal(t, body, string(got), "url=%s", rawURL)
  232. }
  233. require.Empty(t, upstream.requests, "private destinations must never be requested")
  234. // 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。
  235. got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`))
  236. require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String())
  237. require.Len(t, upstream.requests, 1)
  238. }
  239. func TestIsBackfillImageContent(t *testing.T) {
  240. webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...)
  241. tests := []struct {
  242. name string
  243. data []byte
  244. want bool
  245. }{
  246. {name: "png", data: b64BackfillPNGBytes, want: true},
  247. {name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true},
  248. {name: "webp", data: webp, want: true},
  249. {name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true},
  250. {name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false},
  251. {name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false},
  252. {name: "svg 文本", data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: false},
  253. {name: "html", data: []byte("<html>login required</html>"), want: false},
  254. {name: "json", data: []byte(`{"error":"unauthorized"}`), want: false},
  255. {name: "空", data: nil, want: false},
  256. }
  257. for _, tt := range tests {
  258. t.Run(tt.name, func(t *testing.T) {
  259. require.Equal(t, tt.want, isBackfillImageContent(tt.data))
  260. })
  261. }
  262. }
  263. func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) {
  264. upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
  265. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  266. account := b64BackfillAccount(true)
  267. for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} {
  268. got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body))
  269. require.Equal(t, body, string(got), "body=%q", body)
  270. }
  271. require.Empty(t, upstream.requests)
  272. }
  273. func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) {
  274. gin.SetMode(gin.TestMode)
  275. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
  276. req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
  277. req.Header.Set("Content-Type", "application/json")
  278. rec := httptest.NewRecorder()
  279. c, _ := gin.CreateTestContext(rec)
  280. c.Request = req
  281. c.Set("api_key", &APIKey{ID: 42})
  282. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  283. parsed, err := svc.ParseOpenAIImagesRequest(c, body)
  284. require.NoError(t, err)
  285. upstream := &httpUpstreamRecorder{
  286. responses: []*http.Response{
  287. {
  288. StatusCode: http.StatusOK,
  289. Header: http.Header{
  290. "Content-Type": []string{"application/json"},
  291. "X-Request-Id": []string{"req_img_url"},
  292. },
  293. Body: io.NopCloser(strings.NewReader(
  294. `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`,
  295. )),
  296. },
  297. b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes),
  298. },
  299. }
  300. svc.httpUpstream = upstream
  301. result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "")
  302. require.NoError(t, err)
  303. require.NotNil(t, result)
  304. require.Equal(t, 1, result.ImageCount)
  305. require.False(t, result.Stream)
  306. require.Len(t, upstream.requests, 2)
  307. require.Equal(t, http.MethodPost, upstream.requests[0].Method)
  308. require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String())
  309. require.Equal(t, http.MethodGet, upstream.requests[1].Method)
  310. require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String())
  311. require.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context()))
  312. require.Equal(t, http.StatusOK, rec.Code)
  313. wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes)
  314. require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String())
  315. require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String())
  316. require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String())
  317. require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int())
  318. }
  319. func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) {
  320. gin.SetMode(gin.TestMode)
  321. body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
  322. req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
  323. req.Header.Set("Content-Type", "application/json")
  324. rec := httptest.NewRecorder()
  325. c, _ := gin.CreateTestContext(rec)
  326. c.Request = req
  327. c.Set("api_key", &APIKey{ID: 42})
  328. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  329. parsed, err := svc.ParseOpenAIImagesRequest(c, body)
  330. require.NoError(t, err)
  331. upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}`
  332. upstream := &httpUpstreamRecorder{
  333. responses: []*http.Response{
  334. {
  335. StatusCode: http.StatusOK,
  336. Header: http.Header{"Content-Type": []string{"application/json"}},
  337. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  338. },
  339. b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes),
  340. },
  341. }
  342. svc.httpUpstream = upstream
  343. result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "")
  344. require.NoError(t, err)
  345. require.NotNil(t, result)
  346. require.Equal(t, 1, result.ImageCount)
  347. require.Len(t, upstream.requests, 1)
  348. require.Equal(t, http.StatusOK, rec.Code)
  349. require.Equal(t, upstreamBody, rec.Body.String())
  350. }