Claude Code commited on
Commit
bc2679e
·
2 Parent(s): 8e7e8f3dcc3f07

Remove image response adapter

Browse files
Files changed (5) hide show
  1. Dockerfile +5 -15
  2. README.md +0 -13
  3. adapter/go.mod +0 -3
  4. adapter/main.go +0 -565
  5. adapter/main_test.go +0 -444
Dockerfile CHANGED
@@ -1,20 +1,10 @@
1
- FROM golang:1.24-alpine AS adapter-builder
2
-
3
- WORKDIR /src/adapter
4
- COPY adapter/ ./
5
- RUN go test ./... && \
6
- CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/image-response-adapter .
7
-
8
  FROM calciumion/new-api:latest
9
 
10
- ENV PORT=7860 \
11
- INTERNAL_NEW_API_PORT=3000 \
12
- IMAGE_RESPONSE_MODE=b64_json \
13
- IMAGE_CONVERSION_MAX_CONCURRENCY=4
14
  EXPOSE 7860
15
 
 
16
  USER root
17
- RUN mkdir -p /data && chmod 777 /data
18
-
19
- COPY --from=adapter-builder /out/image-response-adapter /image-response-adapter
20
- ENTRYPOINT ["/image-response-adapter"]
 
1
+ # 使用官方正版镜像 (Calcium-Ion)
 
 
 
 
 
 
2
  FROM calciumion/new-api:latest
3
 
4
+ # 设置 Hugging Face 要求的端口
5
+ ENV PORT=7860
 
 
6
  EXPOSE 7860
7
 
8
+ # 解决权限问题 (必须加,否则会报错无法写入数据库)
9
  USER root
10
+ RUN mkdir -p /data && chmod 777 /data
 
 
 
README.md CHANGED
@@ -9,16 +9,3 @@ license: mit
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
12
-
13
- This Space runs the official New API image behind a small response adapter. All
14
- requests are proxied unchanged except successful `/v1/images/generations` and
15
- `/v1/images/edits` responses containing `data[].url` without `b64_json`. For
16
- those responses, the adapter downloads the public image URL and returns the
17
- official Base64 response shape expected by OpenAI-compatible clients.
18
-
19
- The default mode is `b64_json`. A caller that needs the original URL can set
20
- `response_format: "url"` in a JSON generation request or send
21
- `X-Image-Response-Format: url`. The custom header is removed before forwarding.
22
- Set `IMAGE_RESPONSE_MODE=url` to make URL passthrough the Space-wide default.
23
- `IMAGE_CONVERSION_MAX_CONCURRENCY` limits simultaneous URL downloads and
24
- defaults to `4` for CPU Basic hardware.
 
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
adapter/go.mod DELETED
@@ -1,3 +0,0 @@
1
- module new-api-image-response-adapter
2
-
3
- go 1.23
 
 
 
 
adapter/main.go DELETED
@@ -1,565 +0,0 @@
1
- package main
2
-
3
- import (
4
- "bytes"
5
- "context"
6
- "encoding/base64"
7
- "encoding/json"
8
- "errors"
9
- "fmt"
10
- "io"
11
- "log"
12
- "net"
13
- "net/http"
14
- "net/http/httputil"
15
- "net/url"
16
- "os"
17
- "os/exec"
18
- "os/signal"
19
- "strconv"
20
- "strings"
21
- "syscall"
22
- "time"
23
- )
24
-
25
- const (
26
- defaultExternalPort = "7860"
27
- defaultInternalPort = "3000"
28
- defaultAutoImageSize = "1024x1024"
29
- maxImageResponseBytes = 96 << 20
30
- maxDownloadedImageSize = 64 << 20
31
- imageDownloadTimeout = 3 * time.Minute
32
- shutdownTimeout = 15 * time.Second
33
- defaultMaxConcurrency = 4
34
- maxModeRequestBody = 1 << 20
35
- responseFormatHeader = "X-Image-Response-Format"
36
- )
37
-
38
- type imageDownloader func(context.Context, string) ([]byte, error)
39
- type imageResponseMode string
40
-
41
- const (
42
- imageResponseModeBase64 imageResponseMode = "b64_json"
43
- imageResponseModeURL imageResponseMode = "url"
44
- )
45
-
46
- type responseModeContextKey struct{}
47
-
48
- func main() {
49
- if err := run(); err != nil {
50
- log.Fatal(err)
51
- }
52
- }
53
-
54
- func run() error {
55
- externalPort := envOrDefault("PORT", defaultExternalPort)
56
- internalPort := envOrDefault("INTERNAL_NEW_API_PORT", defaultInternalPort)
57
- if externalPort == internalPort {
58
- return fmt.Errorf("PORT and INTERNAL_NEW_API_PORT must differ")
59
- }
60
- defaultMode, err := parseImageResponseMode(envOrDefault("IMAGE_RESPONSE_MODE", string(imageResponseModeBase64)))
61
- if err != nil {
62
- return err
63
- }
64
- maxConcurrency, err := envInt("IMAGE_CONVERSION_MAX_CONCURRENCY", defaultMaxConcurrency, 1, 32)
65
- if err != nil {
66
- return err
67
- }
68
- autoImageSize, err := parseAutoImageSize(envOrDefault("IMAGE_AUTO_SIZE", defaultAutoImageSize))
69
- if err != nil {
70
- return err
71
- }
72
-
73
- child := exec.Command("/new-api")
74
- child.Dir = "/data"
75
- child.Env = replaceEnv(os.Environ(), "PORT", internalPort)
76
- child.Stdout = os.Stdout
77
- child.Stderr = os.Stderr
78
- child.Stdin = os.Stdin
79
- if err := child.Start(); err != nil {
80
- return fmt.Errorf("start New API: %w", err)
81
- }
82
-
83
- backend, err := url.Parse("http://127.0.0.1:" + internalPort)
84
- if err != nil {
85
- _ = child.Process.Kill()
86
- return fmt.Errorf("parse internal New API URL: %w", err)
87
- }
88
-
89
- server := &http.Server{
90
- Addr: ":" + externalPort,
91
- Handler: newProxy(backend, newPublicImageClient(), defaultMode, maxConcurrency, autoImageSize),
92
- ReadHeaderTimeout: 30 * time.Second,
93
- IdleTimeout: 2 * time.Minute,
94
- }
95
-
96
- childDone := make(chan error, 1)
97
- go func() { childDone <- child.Wait() }()
98
- serverDone := make(chan error, 1)
99
- go func() { serverDone <- server.ListenAndServe() }()
100
-
101
- signalCh := make(chan os.Signal, 1)
102
- signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
103
- defer signal.Stop(signalCh)
104
-
105
- log.Printf(
106
- "image response adapter listening on :%s; New API on 127.0.0.1:%s; default=%s; max_concurrency=%d; auto_size=%s",
107
- externalPort,
108
- internalPort,
109
- defaultMode,
110
- maxConcurrency,
111
- displayAutoImageSize(autoImageSize),
112
- )
113
-
114
- select {
115
- case sig := <-signalCh:
116
- log.Printf("received %s, shutting down", sig)
117
- shutdownServer(server)
118
- stopChild(child, childDone, sig)
119
- return nil
120
- case err := <-childDone:
121
- shutdownServer(server)
122
- if err == nil {
123
- return errors.New("New API exited")
124
- }
125
- return fmt.Errorf("New API exited: %w", err)
126
- case err := <-serverDone:
127
- if err != nil && !errors.Is(err, http.ErrServerClosed) {
128
- stopChild(child, childDone, syscall.SIGTERM)
129
- return fmt.Errorf("adapter server: %w", err)
130
- }
131
- stopChild(child, childDone, syscall.SIGTERM)
132
- return nil
133
- }
134
- }
135
-
136
- func newProxy(
137
- backend *url.URL,
138
- downloadClient *http.Client,
139
- defaultMode imageResponseMode,
140
- maxConcurrency int,
141
- autoImageSize string,
142
- ) *httputil.ReverseProxy {
143
- proxy := httputil.NewSingleHostReverseProxy(backend)
144
- originalDirector := proxy.Director
145
- proxy.Director = func(request *http.Request) {
146
- if isImageEndpoint(request.URL.Path) {
147
- mode := imageResponseModeForRequest(request, defaultMode)
148
- request.Header.Del(responseFormatHeader)
149
- if normalizeAutoImageSize(request, autoImageSize) {
150
- log.Printf("normalized auto image size to %s", autoImageSize)
151
- }
152
- requestWithMode := request.WithContext(context.WithValue(request.Context(), responseModeContextKey{}, mode))
153
- *request = *requestWithMode
154
- }
155
- originalDirector(request)
156
- }
157
- transport := http.DefaultTransport.(*http.Transport).Clone()
158
- transport.DisableCompression = true
159
- proxy.Transport = transport
160
- proxy.FlushInterval = -1
161
- conversionSlots := make(chan struct{}, maxConcurrency)
162
- proxy.ModifyResponse = func(response *http.Response) error {
163
- return adaptImageResponse(response, func(ctx context.Context, imageURL string) ([]byte, error) {
164
- select {
165
- case conversionSlots <- struct{}{}:
166
- defer func() { <-conversionSlots }()
167
- case <-ctx.Done():
168
- return nil, ctx.Err()
169
- }
170
- return downloadImage(ctx, downloadClient, imageURL)
171
- })
172
- }
173
- proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
174
- log.Printf("proxy request failed path=%q: %v", request.URL.Path, err)
175
- if strings.HasPrefix(request.URL.Path, "/v1/") {
176
- writer.Header().Set("Content-Type", "application/json")
177
- writer.WriteHeader(http.StatusBadGateway)
178
- _, _ = io.WriteString(writer, `{"error":{"message":"image response adaptation failed","type":"api_error"}}`)
179
- return
180
- }
181
- http.Error(writer, "Bad gateway", http.StatusBadGateway)
182
- }
183
- return proxy
184
- }
185
-
186
- func adaptImageResponse(response *http.Response, downloader imageDownloader) error {
187
- if response == nil || response.Request == nil || !isImageEndpoint(response.Request.URL.Path) {
188
- return nil
189
- }
190
- if imageResponseModeFromContext(response.Request.Context()) == imageResponseModeURL {
191
- return nil
192
- }
193
- if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
194
- return nil
195
- }
196
- contentType := strings.ToLower(response.Header.Get("Content-Type"))
197
- if contentType != "" && !strings.Contains(contentType, "json") {
198
- return nil
199
- }
200
-
201
- body, err := readLimited(response.Body, maxImageResponseBytes)
202
- if err != nil {
203
- return fmt.Errorf("read image response: %w", err)
204
- }
205
- converted, count, err := convertImageURLs(response.Request.Context(), body, downloader)
206
- if err != nil {
207
- return fmt.Errorf("convert image response: %w", err)
208
- }
209
- if count == 0 {
210
- resetResponseBody(response, body)
211
- return nil
212
- }
213
-
214
- resetResponseBody(response, converted)
215
- response.Header.Set("Content-Type", "application/json")
216
- response.Header.Del("Content-Encoding")
217
- response.Header.Del("ETag")
218
- log.Printf("converted %d image URL response item(s) to b64_json", count)
219
- return nil
220
- }
221
-
222
- func convertImageURLs(ctx context.Context, body []byte, downloader imageDownloader) ([]byte, int, error) {
223
- var envelope map[string]json.RawMessage
224
- if err := json.Unmarshal(body, &envelope); err != nil {
225
- return body, 0, nil
226
- }
227
- rawData, ok := envelope["data"]
228
- if !ok {
229
- return body, 0, nil
230
- }
231
-
232
- var data []map[string]json.RawMessage
233
- if err := json.Unmarshal(rawData, &data); err != nil {
234
- return body, 0, nil
235
- }
236
-
237
- converted := 0
238
- for _, item := range data {
239
- if nonEmptyJSONString(item["b64_json"]) {
240
- continue
241
- }
242
- imageURL, ok := decodeNonEmptyString(item["url"])
243
- if !ok {
244
- continue
245
- }
246
- imageBytes, err := downloader(ctx, imageURL)
247
- if err != nil {
248
- return nil, 0, err
249
- }
250
- encoded, err := json.Marshal(base64.StdEncoding.EncodeToString(imageBytes))
251
- if err != nil {
252
- return nil, 0, fmt.Errorf("encode image data: %w", err)
253
- }
254
- item["b64_json"] = encoded
255
- delete(item, "url")
256
- converted++
257
- }
258
- if converted == 0 {
259
- return body, 0, nil
260
- }
261
-
262
- updatedData, err := json.Marshal(data)
263
- if err != nil {
264
- return nil, 0, fmt.Errorf("encode image response data: %w", err)
265
- }
266
- envelope["data"] = updatedData
267
- updatedBody, err := json.Marshal(envelope)
268
- if err != nil {
269
- return nil, 0, fmt.Errorf("encode image response: %w", err)
270
- }
271
- return updatedBody, converted, nil
272
- }
273
-
274
- func downloadImage(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
275
- parsed, err := url.Parse(rawURL)
276
- if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
277
- return nil, errors.New("upstream returned an invalid image URL")
278
- }
279
-
280
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
281
- if err != nil {
282
- return nil, errors.New("create image download request")
283
- }
284
- request.Header.Set("Accept", "image/*")
285
- request.Header.Set("User-Agent", "New-API-Image-Response-Adapter/1.0")
286
-
287
- response, err := client.Do(request)
288
- if err != nil {
289
- return nil, errors.New("download upstream image")
290
- }
291
- defer response.Body.Close()
292
- if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
293
- return nil, fmt.Errorf("image download returned HTTP %d", response.StatusCode)
294
- }
295
-
296
- imageBytes, err := readLimited(response.Body, maxDownloadedImageSize)
297
- if err != nil {
298
- return nil, fmt.Errorf("read upstream image: %w", err)
299
- }
300
- if len(imageBytes) == 0 {
301
- return nil, errors.New("upstream image was empty")
302
- }
303
- detectedType := http.DetectContentType(imageBytes[:min(len(imageBytes), 512)])
304
- declaredType := strings.ToLower(response.Header.Get("Content-Type"))
305
- if !strings.HasPrefix(detectedType, "image/") && !strings.HasPrefix(declaredType, "image/") {
306
- return nil, errors.New("downloaded URL did not return an image")
307
- }
308
- return imageBytes, nil
309
- }
310
-
311
- func newPublicImageClient() *http.Client {
312
- dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
313
- transport := http.DefaultTransport.(*http.Transport).Clone()
314
- transport.Proxy = nil
315
- transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
316
- host, port, err := net.SplitHostPort(address)
317
- if err != nil {
318
- return nil, errors.New("invalid image host")
319
- }
320
- addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
321
- if err != nil {
322
- return nil, errors.New("resolve image host")
323
- }
324
- for _, address := range addresses {
325
- if isPublicIP(address.IP) {
326
- return dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port))
327
- }
328
- }
329
- return nil, errors.New("image URL resolved only to non-public addresses")
330
- }
331
- return &http.Client{
332
- Transport: transport,
333
- Timeout: imageDownloadTimeout,
334
- CheckRedirect: func(request *http.Request, via []*http.Request) error {
335
- if len(via) >= 5 {
336
- return errors.New("too many image URL redirects")
337
- }
338
- return nil
339
- },
340
- }
341
- }
342
-
343
- func isPublicIP(ip net.IP) bool {
344
- return ip != nil &&
345
- !ip.IsLoopback() &&
346
- !ip.IsPrivate() &&
347
- !ip.IsLinkLocalUnicast() &&
348
- !ip.IsLinkLocalMulticast() &&
349
- !ip.IsUnspecified() &&
350
- !ip.IsMulticast()
351
- }
352
-
353
- func isImageEndpoint(path string) bool {
354
- return path == "/v1/images/generations" || path == "/v1/images/edits"
355
- }
356
-
357
- func imageResponseModeForRequest(request *http.Request, fallback imageResponseMode) imageResponseMode {
358
- if request == nil {
359
- return fallback
360
- }
361
- if mode, err := parseImageResponseMode(request.Header.Get(responseFormatHeader)); err == nil && mode != "" {
362
- return mode
363
- }
364
- if request.URL.Path != "/v1/images/generations" || request.Body == nil {
365
- return fallback
366
- }
367
-
368
- prefix, err := io.ReadAll(io.LimitReader(request.Body, maxModeRequestBody+1))
369
- request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(prefix), request.Body))
370
- if err != nil || len(prefix) > maxModeRequestBody {
371
- return fallback
372
- }
373
- request.Body = io.NopCloser(bytes.NewReader(prefix))
374
-
375
- var payload struct {
376
- ResponseFormat string `json:"response_format"`
377
- }
378
- if err := json.Unmarshal(prefix, &payload); err != nil {
379
- return fallback
380
- }
381
- if mode, err := parseImageResponseMode(payload.ResponseFormat); err == nil && mode != "" {
382
- return mode
383
- }
384
- return fallback
385
- }
386
-
387
- func normalizeAutoImageSize(request *http.Request, fallbackSize string) bool {
388
- if request == nil || request.URL.Path != "/v1/images/generations" || request.Body == nil || fallbackSize == "" {
389
- return false
390
- }
391
-
392
- body, complete := readAndRestoreRequestBody(request, maxModeRequestBody)
393
- if !complete {
394
- return false
395
- }
396
- var payload map[string]json.RawMessage
397
- if err := json.Unmarshal(body, &payload); err != nil {
398
- return false
399
- }
400
- model, ok := decodeNonEmptyString(payload["model"])
401
- if !ok || !strings.HasPrefix(strings.ToLower(model), "gpt-image-") {
402
- return false
403
- }
404
- size, hasSize := decodeNonEmptyString(payload["size"])
405
- if hasSize && !strings.EqualFold(strings.TrimSpace(size), "auto") {
406
- return false
407
- }
408
- encodedSize, err := json.Marshal(fallbackSize)
409
- if err != nil {
410
- return false
411
- }
412
- payload["size"] = encodedSize
413
- updatedBody, err := json.Marshal(payload)
414
- if err != nil {
415
- return false
416
- }
417
- request.Body = io.NopCloser(bytes.NewReader(updatedBody))
418
- request.ContentLength = int64(len(updatedBody))
419
- request.Header.Set("Content-Length", strconv.Itoa(len(updatedBody)))
420
- return true
421
- }
422
-
423
- func readAndRestoreRequestBody(request *http.Request, limit int64) ([]byte, bool) {
424
- prefix, err := io.ReadAll(io.LimitReader(request.Body, limit+1))
425
- request.Body = io.NopCloser(io.MultiReader(bytes.NewReader(prefix), request.Body))
426
- if err != nil || int64(len(prefix)) > limit {
427
- return nil, false
428
- }
429
- request.Body = io.NopCloser(bytes.NewReader(prefix))
430
- return prefix, true
431
- }
432
-
433
- func imageResponseModeFromContext(ctx context.Context) imageResponseMode {
434
- if mode, ok := ctx.Value(responseModeContextKey{}).(imageResponseMode); ok {
435
- return mode
436
- }
437
- return imageResponseModeBase64
438
- }
439
-
440
- func parseImageResponseMode(raw string) (imageResponseMode, error) {
441
- switch strings.ToLower(strings.TrimSpace(raw)) {
442
- case "":
443
- return "", nil
444
- case "b64_json", "base64":
445
- return imageResponseModeBase64, nil
446
- case "url", "passthrough":
447
- return imageResponseModeURL, nil
448
- default:
449
- return "", fmt.Errorf("invalid image response mode %q: use b64_json or url", raw)
450
- }
451
- }
452
-
453
- func parseAutoImageSize(raw string) (string, error) {
454
- normalized := strings.ToLower(strings.TrimSpace(raw))
455
- switch normalized {
456
- case "off", "disabled", "passthrough":
457
- return "", nil
458
- }
459
- parts := strings.Split(normalized, "x")
460
- if len(parts) != 2 {
461
- return "", fmt.Errorf("IMAGE_AUTO_SIZE must be WIDTHxHEIGHT or off")
462
- }
463
- width, widthErr := strconv.Atoi(parts[0])
464
- height, heightErr := strconv.Atoi(parts[1])
465
- if widthErr != nil || heightErr != nil || width < 1 || height < 1 {
466
- return "", fmt.Errorf("IMAGE_AUTO_SIZE must be WIDTHxHEIGHT or off")
467
- }
468
- return strconv.Itoa(width) + "x" + strconv.Itoa(height), nil
469
- }
470
-
471
- func displayAutoImageSize(size string) string {
472
- if size == "" {
473
- return "off"
474
- }
475
- return size
476
- }
477
-
478
- func decodeNonEmptyString(raw json.RawMessage) (string, bool) {
479
- if len(raw) == 0 || string(raw) == "null" {
480
- return "", false
481
- }
482
- var value string
483
- if err := json.Unmarshal(raw, &value); err != nil || strings.TrimSpace(value) == "" {
484
- return "", false
485
- }
486
- return value, true
487
- }
488
-
489
- func nonEmptyJSONString(raw json.RawMessage) bool {
490
- _, ok := decodeNonEmptyString(raw)
491
- return ok
492
- }
493
-
494
- func readLimited(reader io.Reader, limit int64) ([]byte, error) {
495
- limited := io.LimitReader(reader, limit+1)
496
- data, err := io.ReadAll(limited)
497
- if err != nil {
498
- return nil, err
499
- }
500
- if int64(len(data)) > limit {
501
- return nil, fmt.Errorf("payload exceeds %d bytes", limit)
502
- }
503
- return data, nil
504
- }
505
-
506
- func resetResponseBody(response *http.Response, body []byte) {
507
- response.Body = io.NopCloser(bytes.NewReader(body))
508
- response.ContentLength = int64(len(body))
509
- response.Header.Set("Content-Length", strconv.Itoa(len(body)))
510
- }
511
-
512
- func envOrDefault(key, fallback string) string {
513
- if value := strings.TrimSpace(os.Getenv(key)); value != "" {
514
- return value
515
- }
516
- return fallback
517
- }
518
-
519
- func envInt(key string, fallback, minimum, maximum int) (int, error) {
520
- raw := strings.TrimSpace(os.Getenv(key))
521
- if raw == "" {
522
- return fallback, nil
523
- }
524
- value, err := strconv.Atoi(raw)
525
- if err != nil || value < minimum || value > maximum {
526
- return 0, fmt.Errorf("%s must be an integer from %d to %d", key, minimum, maximum)
527
- }
528
- return value, nil
529
- }
530
-
531
- func replaceEnv(env []string, key, value string) []string {
532
- prefix := key + "="
533
- result := make([]string, 0, len(env)+1)
534
- for _, entry := range env {
535
- if !strings.HasPrefix(entry, prefix) {
536
- result = append(result, entry)
537
- }
538
- }
539
- return append(result, prefix+value)
540
- }
541
-
542
- func shutdownServer(server *http.Server) {
543
- ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
544
- defer cancel()
545
- if err := server.Shutdown(ctx); err != nil {
546
- log.Printf("adapter shutdown: %v", err)
547
- }
548
- }
549
-
550
- func stopChild(child *exec.Cmd, done <-chan error, sig os.Signal) {
551
- if child.Process == nil {
552
- return
553
- }
554
- _ = child.Process.Signal(sig)
555
- select {
556
- case <-done:
557
- return
558
- case <-time.After(shutdownTimeout):
559
- _ = child.Process.Kill()
560
- select {
561
- case <-done:
562
- case <-time.After(time.Second):
563
- }
564
- }
565
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
adapter/main_test.go DELETED
@@ -1,444 +0,0 @@
1
- package main
2
-
3
- import (
4
- "context"
5
- "encoding/base64"
6
- "encoding/json"
7
- "fmt"
8
- "io"
9
- "net"
10
- "net/http"
11
- "net/http/httptest"
12
- "net/url"
13
- "strings"
14
- "sync"
15
- "sync/atomic"
16
- "testing"
17
- "time"
18
- )
19
-
20
- func TestConvertImageURLs(t *testing.T) {
21
- input := []byte(`{"created":123,"data":[{"url":"https://cdn.example/image.png","revised_prompt":"final"}],"usage":{"total_tokens":9}}`)
22
- fetches := 0
23
- output, count, err := convertImageURLs(context.Background(), input, func(_ context.Context, rawURL string) ([]byte, error) {
24
- fetches++
25
- if rawURL != "https://cdn.example/image.png" {
26
- t.Fatalf("unexpected URL: %s", rawURL)
27
- }
28
- return []byte("image-bytes"), nil
29
- })
30
- if err != nil {
31
- t.Fatal(err)
32
- }
33
- if count != 1 || fetches != 1 {
34
- t.Fatalf("count=%d fetches=%d", count, fetches)
35
- }
36
-
37
- var response struct {
38
- Created int64 `json:"created"`
39
- Data []struct {
40
- URL string `json:"url"`
41
- B64JSON string `json:"b64_json"`
42
- RevisedPrompt string `json:"revised_prompt"`
43
- } `json:"data"`
44
- Usage map[string]int `json:"usage"`
45
- }
46
- if err := json.Unmarshal(output, &response); err != nil {
47
- t.Fatal(err)
48
- }
49
- if response.Created != 123 || response.Usage["total_tokens"] != 9 {
50
- t.Fatalf("top-level fields were not preserved: %+v", response)
51
- }
52
- if response.Data[0].URL != "" {
53
- t.Fatalf("URL should be removed: %q", response.Data[0].URL)
54
- }
55
- want := base64.StdEncoding.EncodeToString([]byte("image-bytes"))
56
- if response.Data[0].B64JSON != want || response.Data[0].RevisedPrompt != "final" {
57
- t.Fatalf("unexpected converted item: %+v", response.Data[0])
58
- }
59
- }
60
-
61
- func TestConvertImageURLsLeavesBase64ResponseUnchanged(t *testing.T) {
62
- input := []byte(`{"data":[{"b64_json":"aW1hZ2U="}]}`)
63
- output, count, err := convertImageURLs(context.Background(), input, func(context.Context, string) ([]byte, error) {
64
- t.Fatal("downloader should not be called")
65
- return nil, nil
66
- })
67
- if err != nil {
68
- t.Fatal(err)
69
- }
70
- if count != 0 || string(output) != string(input) {
71
- t.Fatalf("response changed: count=%d output=%s", count, output)
72
- }
73
- }
74
-
75
- func TestConvertImageURLsLeavesOtherJSONUnchanged(t *testing.T) {
76
- input := []byte(`{"error":{"message":"upstream failure"}}`)
77
- output, count, err := convertImageURLs(context.Background(), input, func(context.Context, string) ([]byte, error) {
78
- t.Fatal("downloader should not be called")
79
- return nil, nil
80
- })
81
- if err != nil {
82
- t.Fatal(err)
83
- }
84
- if count != 0 || string(output) != string(input) {
85
- t.Fatalf("response changed: count=%d output=%s", count, output)
86
- }
87
- }
88
-
89
- func TestImageEndpointScope(t *testing.T) {
90
- for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
91
- if !isImageEndpoint(path) {
92
- t.Fatalf("expected image endpoint: %s", path)
93
- }
94
- }
95
- for _, path := range []string{"/v1/responses", "/v1/chat/completions", "/console"} {
96
- if isImageEndpoint(path) {
97
- t.Fatalf("unexpected image endpoint: %s", path)
98
- }
99
- }
100
- }
101
-
102
- func TestImageResponseModeForRequest(t *testing.T) {
103
- tests := []struct {
104
- name string
105
- body string
106
- header string
107
- fallback imageResponseMode
108
- want imageResponseMode
109
- }{
110
- {name: "default base64", body: `{"model":"gpt-image-2"}`, fallback: imageResponseModeBase64, want: imageResponseModeBase64},
111
- {name: "body URL", body: `{"model":"gpt-image-2","response_format":"url"}`, fallback: imageResponseModeBase64, want: imageResponseModeURL},
112
- {name: "body base64", body: `{"response_format":"b64_json"}`, fallback: imageResponseModeURL, want: imageResponseModeBase64},
113
- {name: "header wins", body: `{"response_format":"b64_json"}`, header: "url", fallback: imageResponseModeBase64, want: imageResponseModeURL},
114
- }
115
- for _, test := range tests {
116
- t.Run(test.name, func(t *testing.T) {
117
- request := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(test.body))
118
- if test.header != "" {
119
- request.Header.Set(responseFormatHeader, test.header)
120
- }
121
- got := imageResponseModeForRequest(request, test.fallback)
122
- if got != test.want {
123
- t.Fatalf("mode=%s want=%s", got, test.want)
124
- }
125
- body, err := io.ReadAll(request.Body)
126
- if err != nil || string(body) != test.body {
127
- t.Fatalf("request body was not preserved: body=%q err=%v", body, err)
128
- }
129
- })
130
- }
131
- }
132
-
133
- func TestNormalizeAutoImageSize(t *testing.T) {
134
- tests := []struct {
135
- name string
136
- body string
137
- fallback string
138
- wantChanged bool
139
- wantSize string
140
- }{
141
- {name: "auto", body: `{"model":"gpt-image-2","size":"auto","prompt":"test"}`, fallback: defaultAutoImageSize, wantChanged: true, wantSize: defaultAutoImageSize},
142
- {name: "missing", body: `{"model":"gpt-image-2","prompt":"test"}`, fallback: defaultAutoImageSize, wantChanged: true, wantSize: defaultAutoImageSize},
143
- {name: "explicit", body: `{"model":"gpt-image-2","size":"1536x1024"}`, fallback: defaultAutoImageSize, wantSize: "1536x1024"},
144
- {name: "other model", body: `{"model":"dall-e-3","size":"auto"}`, fallback: defaultAutoImageSize, wantSize: "auto"},
145
- {name: "disabled", body: `{"model":"gpt-image-2","size":"auto"}`, wantSize: "auto"},
146
- }
147
- for _, test := range tests {
148
- t.Run(test.name, func(t *testing.T) {
149
- request := httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(test.body))
150
- changed := normalizeAutoImageSize(request, test.fallback)
151
- if changed != test.wantChanged {
152
- t.Fatalf("changed=%v want=%v", changed, test.wantChanged)
153
- }
154
- var payload struct {
155
- Size string `json:"size"`
156
- }
157
- if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
158
- t.Fatal(err)
159
- }
160
- if payload.Size != test.wantSize {
161
- t.Fatalf("size=%q want=%q", payload.Size, test.wantSize)
162
- }
163
- })
164
- }
165
- }
166
-
167
- func TestParseAutoImageSize(t *testing.T) {
168
- for raw, want := range map[string]string{
169
- "1024x1024": "1024x1024",
170
- " 1536X1024 ": "1536x1024",
171
- "off": "",
172
- } {
173
- got, err := parseAutoImageSize(raw)
174
- if err != nil || got != want {
175
- t.Fatalf("parseAutoImageSize(%q)=%q, %v; want %q", raw, got, err, want)
176
- }
177
- }
178
- for _, raw := range []string{"auto", "1024", "0x1024", "wide x tall"} {
179
- if _, err := parseAutoImageSize(raw); err == nil {
180
- t.Fatalf("parseAutoImageSize(%q) should fail", raw)
181
- }
182
- }
183
- }
184
-
185
- func TestAdaptImageResponseHonorsURLMode(t *testing.T) {
186
- body := `{"data":[{"url":"https://cdn.example/image.png"}]}`
187
- request := httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
188
- request = request.WithContext(context.WithValue(request.Context(), responseModeContextKey{}, imageResponseModeURL))
189
- response := &http.Response{
190
- StatusCode: http.StatusOK,
191
- Header: make(http.Header),
192
- Body: io.NopCloser(strings.NewReader(body)),
193
- ContentLength: int64(len(body)),
194
- Request: request,
195
- }
196
- if err := adaptImageResponse(response, func(context.Context, string) ([]byte, error) {
197
- t.Fatal("downloader should not be called")
198
- return nil, nil
199
- }); err != nil {
200
- t.Fatal(err)
201
- }
202
- got, err := io.ReadAll(response.Body)
203
- if err != nil || string(got) != body {
204
- t.Fatalf("URL response changed: body=%q err=%v", got, err)
205
- }
206
- }
207
-
208
- func TestProxyConvertsDefaultImageResponse(t *testing.T) {
209
- imageBytes := []byte("\x89PNG\r\n\x1a\nimage-data")
210
- imageServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
211
- writer.Header().Set("Content-Type", "image/png")
212
- _, _ = writer.Write(imageBytes)
213
- }))
214
- defer imageServer.Close()
215
-
216
- var forwardedOverride string
217
- var forwardedSize string
218
- backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
219
- forwardedOverride = request.Header.Get(responseFormatHeader)
220
- var payload struct {
221
- Size string `json:"size"`
222
- }
223
- if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
224
- t.Error(err)
225
- }
226
- forwardedSize = payload.Size
227
- writer.Header().Set("Content-Type", "application/json")
228
- _, _ = fmt.Fprintf(writer, `{"created":123,"data":[{"url":%q}]}`, imageServer.URL+"/image.png")
229
- }))
230
- defer backend.Close()
231
-
232
- proxy := httptest.NewServer(newProxy(mustParseURL(t, backend.URL), imageServer.Client(), imageResponseModeBase64, 2, defaultAutoImageSize))
233
- defer proxy.Close()
234
-
235
- request, err := http.NewRequest(http.MethodPost, proxy.URL+"/v1/images/generations", strings.NewReader(`{"model":"gpt-image-2"}`))
236
- if err != nil {
237
- t.Fatal(err)
238
- }
239
- request.Header.Set("Content-Type", "application/json")
240
- request.Header.Set(responseFormatHeader, "b64_json")
241
- response, err := proxy.Client().Do(request)
242
- if err != nil {
243
- t.Fatal(err)
244
- }
245
- defer response.Body.Close()
246
-
247
- var payload struct {
248
- Data []struct {
249
- URL string `json:"url"`
250
- B64JSON string `json:"b64_json"`
251
- } `json:"data"`
252
- }
253
- if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
254
- t.Fatal(err)
255
- }
256
- if response.StatusCode != http.StatusOK {
257
- t.Fatalf("status=%d", response.StatusCode)
258
- }
259
- if forwardedOverride != "" {
260
- t.Fatalf("override header leaked upstream: %q", forwardedOverride)
261
- }
262
- if forwardedSize != defaultAutoImageSize {
263
- t.Fatalf("forwarded size=%q want=%q", forwardedSize, defaultAutoImageSize)
264
- }
265
- if len(payload.Data) != 1 || payload.Data[0].URL != "" || payload.Data[0].B64JSON != base64.StdEncoding.EncodeToString(imageBytes) {
266
- t.Fatalf("unexpected converted response: %+v", payload)
267
- }
268
- }
269
-
270
- func TestProxyHonorsURLResponseFormat(t *testing.T) {
271
- var downloads atomic.Int32
272
- imageServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
273
- downloads.Add(1)
274
- writer.Header().Set("Content-Type", "image/png")
275
- _, _ = writer.Write([]byte("\x89PNG\r\n\x1a\nimage-data"))
276
- }))
277
- defer imageServer.Close()
278
-
279
- backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
280
- writer.Header().Set("Content-Type", "application/json")
281
- _, _ = fmt.Fprintf(writer, `{"data":[{"url":%q}]}`, imageServer.URL+"/image.png")
282
- }))
283
- defer backend.Close()
284
-
285
- proxy := httptest.NewServer(newProxy(mustParseURL(t, backend.URL), imageServer.Client(), imageResponseModeBase64, 2, defaultAutoImageSize))
286
- defer proxy.Close()
287
-
288
- response, err := proxy.Client().Post(
289
- proxy.URL+"/v1/images/generations",
290
- "application/json",
291
- strings.NewReader(`{"model":"gpt-image-2","response_format":"url"}`),
292
- )
293
- if err != nil {
294
- t.Fatal(err)
295
- }
296
- defer response.Body.Close()
297
- body, err := io.ReadAll(response.Body)
298
- if err != nil {
299
- t.Fatal(err)
300
- }
301
- if !strings.Contains(string(body), imageServer.URL) {
302
- t.Fatalf("URL response was not preserved: %s", body)
303
- }
304
- if downloads.Load() != 0 {
305
- t.Fatalf("URL mode downloaded %d image(s)", downloads.Load())
306
- }
307
- }
308
-
309
- func TestProxyLeavesNonImageResponseUnchanged(t *testing.T) {
310
- const body = `{"id":"resp_123","output":[{"type":"message"}]}`
311
- backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
312
- if request.URL.Path != "/v1/responses" {
313
- t.Fatalf("unexpected path: %s", request.URL.Path)
314
- }
315
- writer.Header().Set("Content-Type", "application/json")
316
- writer.Header().Set("X-Upstream", "preserved")
317
- _, _ = io.WriteString(writer, body)
318
- }))
319
- defer backend.Close()
320
-
321
- proxy := httptest.NewServer(newProxy(mustParseURL(t, backend.URL), http.DefaultClient, imageResponseModeBase64, 2, defaultAutoImageSize))
322
- defer proxy.Close()
323
- response, err := proxy.Client().Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(`{"model":"gpt-5"}`))
324
- if err != nil {
325
- t.Fatal(err)
326
- }
327
- defer response.Body.Close()
328
- got, err := io.ReadAll(response.Body)
329
- if err != nil {
330
- t.Fatal(err)
331
- }
332
- if string(got) != body || response.Header.Get("X-Upstream") != "preserved" {
333
- t.Fatalf("non-image response changed: body=%s headers=%v", got, response.Header)
334
- }
335
- }
336
-
337
- func TestProxyLimitsConcurrentImageDownloads(t *testing.T) {
338
- const (
339
- requestCount = 6
340
- concurrency = 2
341
- assertionWait = 200 * time.Millisecond
342
- )
343
- entered := make(chan struct{}, requestCount)
344
- release := make(chan struct{})
345
- var releaseOnce sync.Once
346
- releaseDownloads := func() { releaseOnce.Do(func() { close(release) }) }
347
- defer releaseDownloads()
348
- var active atomic.Int32
349
- var maximum atomic.Int32
350
- imageServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
351
- current := active.Add(1)
352
- defer active.Add(-1)
353
- for current > maximum.Load() && !maximum.CompareAndSwap(maximum.Load(), current) {
354
- }
355
- entered <- struct{}{}
356
- <-release
357
- writer.Header().Set("Content-Type", "image/png")
358
- _, _ = writer.Write([]byte("\x89PNG\r\n\x1a\nimage-data"))
359
- }))
360
- defer imageServer.Close()
361
-
362
- backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
363
- writer.Header().Set("Content-Type", "application/json")
364
- _, _ = fmt.Fprintf(writer, `{"data":[{"url":%q}]}`, imageServer.URL+"/image.png")
365
- }))
366
- defer backend.Close()
367
- proxy := httptest.NewServer(newProxy(mustParseURL(t, backend.URL), imageServer.Client(), imageResponseModeBase64, concurrency, defaultAutoImageSize))
368
- defer proxy.Close()
369
-
370
- errors := make(chan error, requestCount)
371
- var requests sync.WaitGroup
372
- for range requestCount {
373
- requests.Add(1)
374
- go func() {
375
- defer requests.Done()
376
- response, err := proxy.Client().Post(proxy.URL+"/v1/images/generations", "application/json", strings.NewReader(`{"model":"gpt-image-2"}`))
377
- if err != nil {
378
- errors <- err
379
- return
380
- }
381
- _, readErr := io.Copy(io.Discard, response.Body)
382
- closeErr := response.Body.Close()
383
- if readErr != nil {
384
- errors <- readErr
385
- } else if closeErr != nil {
386
- errors <- closeErr
387
- }
388
- }()
389
- }
390
-
391
- for range concurrency {
392
- select {
393
- case <-entered:
394
- case <-time.After(2 * time.Second):
395
- t.Fatal("concurrent downloads did not start")
396
- }
397
- }
398
- select {
399
- case <-entered:
400
- t.Fatal("download concurrency limit was exceeded")
401
- case <-time.After(assertionWait):
402
- }
403
- releaseDownloads()
404
-
405
- done := make(chan struct{})
406
- go func() {
407
- requests.Wait()
408
- close(done)
409
- }()
410
- select {
411
- case <-done:
412
- case <-time.After(5 * time.Second):
413
- t.Fatal("proxy requests did not finish")
414
- }
415
- close(errors)
416
- for err := range errors {
417
- t.Error(err)
418
- }
419
- if maximum.Load() > concurrency {
420
- t.Fatalf("maximum concurrent downloads=%d want<=%d", maximum.Load(), concurrency)
421
- }
422
- }
423
-
424
- func mustParseURL(t *testing.T, rawURL string) *url.URL {
425
- t.Helper()
426
- parsed, err := url.Parse(rawURL)
427
- if err != nil {
428
- t.Fatal(err)
429
- }
430
- return parsed
431
- }
432
-
433
- func TestPublicIPGuard(t *testing.T) {
434
- for _, raw := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.2", "169.254.1.1", "::1"} {
435
- if isPublicIP(net.ParseIP(raw)) {
436
- t.Fatalf("private address accepted: %s", raw)
437
- }
438
- }
439
- for _, raw := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
440
- if !isPublicIP(net.ParseIP(raw)) {
441
- t.Fatalf("public address rejected: %s", raw)
442
- }
443
- }
444
- }