Remove image response adapter
Browse files- Dockerfile +5 -15
- README.md +0 -13
- adapter/go.mod +0 -3
- adapter/main.go +0 -565
- adapter/main_test.go +0 -444
Dockerfile
CHANGED
|
@@ -1,20 +1,10 @@
|
|
| 1 |
-
|
| 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 |
-
|
| 11 |
-
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|