agagsder commited on
Commit
0cef4ab
·
verified ·
1 Parent(s): bc2679e

Add optional Codex image Base64 compatibility

Browse files
Files changed (2) hide show
  1. Dockerfile +41 -7
  2. codex-image-b64.patch +291 -0
Dockerfile CHANGED
@@ -1,10 +1,44 @@
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
 
1
+ FROM alpine:3.22 AS source
 
2
 
3
+ RUN apk add --no-cache git
4
+ RUN git clone --depth 1 --branch v1.0.0-rc.22 https://github.com/QuantumNous/new-api.git /src
5
+ COPY codex-image-b64.patch /tmp/codex-image-b64.patch
6
+ RUN git -C /src apply --check /tmp/codex-image-b64.patch \
7
+ && git -C /src apply /tmp/codex-image-b64.patch
8
+
9
+ FROM oven/bun:1 AS web-builder
10
+
11
+ WORKDIR /build/web
12
+ COPY --from=source /src/web/package.json /src/web/bun.lock ./
13
+ RUN bun install --frozen-lockfile
14
+ COPY --from=source /src/web ./
15
+ COPY --from=source /src/VERSION /build/VERSION
16
+ RUN DISABLE_ESLINT_PLUGIN=true VITE_REACT_APP_VERSION=v1.0.0-rc.22 bun run build
17
+
18
+ FROM golang:1.26.1-alpine AS go-builder
19
 
20
+ ENV GO111MODULE=on CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOEXPERIMENT=greenteagc
21
+ WORKDIR /build
22
+ COPY --from=source /src/go.mod /src/go.sum ./
23
+ RUN go mod download
24
+ COPY --from=source /src ./
25
+ COPY --from=web-builder /build/web/dist ./web/dist
26
+ RUN go test ./relay/channel/openai \
27
+ && go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=v1.0.0-rc.22-codex-image-b64'" -o new-api
28
+
29
+ FROM debian:bookworm-slim
30
+
31
+ RUN apt-get update \
32
+ && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
33
+ && rm -rf /var/lib/apt/lists/* \
34
+ && update-ca-certificates \
35
+ && mkdir -p /data \
36
+ && chmod 777 /data
37
+
38
+ COPY --from=go-builder /build/new-api /new-api
39
+ COPY --from=source /src/LICENSE /src/NOTICE /src/THIRD-PARTY-LICENSES.md /licenses/
40
+ ENV PORT=7860 VERSION=v1.0.0-rc.22-codex-image-b64
41
+ EXPOSE 7860
42
+ WORKDIR /data
43
  USER root
44
+ ENTRYPOINT ["/new-api"]
codex-image-b64.patch ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ From a7a127e93518b4b91820d23b4b508a70f9834a98 Mon Sep 17 00:00:00 2001
2
+ From: Claude Code <wechatbot@dev.local>
3
+ Date: Wed, 29 Jul 2026 18:14:19 +0800
4
+ Subject: [PATCH] Add optional image URL base64 compatibility
5
+
6
+ ---
7
+ .env.example | 2 +
8
+ relay/channel/openai/image_url_compat.go | 162 ++++++++++++++++++
9
+ relay/channel/openai/image_url_compat_test.go | 70 ++++++++
10
+ relay/channel/openai/relay_image.go | 10 ++
11
+ 4 files changed, 244 insertions(+)
12
+ create mode 100644 relay/channel/openai/image_url_compat.go
13
+ create mode 100644 relay/channel/openai/image_url_compat_test.go
14
+
15
+ diff --git a/.env.example b/.env.example
16
+ index d62e114..76ea82f 100644
17
+ --- a/.env.example
18
+ +++ b/.env.example
19
+ @@ -1,5 +1,7 @@
20
+ # 端口号
21
+ # PORT=3000
22
+ +# Convert URL-based OpenAI image responses to b64_json for strict clients such as Codex.
23
+ +# CODEX_IMAGE_URL_TO_B64=false
24
+ # 前端基础URL
25
+ # FRONTEND_BASE_URL=https://your-frontend-url.com
26
+
27
+ diff --git a/relay/channel/openai/image_url_compat.go b/relay/channel/openai/image_url_compat.go
28
+ new file mode 100644
29
+ index 0000000..e0f222c
30
+ --- /dev/null
31
+ +++ b/relay/channel/openai/image_url_compat.go
32
+ @@ -0,0 +1,162 @@
33
+ +package openai
34
+ +
35
+ +import (
36
+ + "encoding/base64"
37
+ + "encoding/json"
38
+ + "fmt"
39
+ + "io"
40
+ + "net/http"
41
+ + "strings"
42
+ + "time"
43
+ +
44
+ + "github.com/QuantumNous/new-api/common"
45
+ +)
46
+ +
47
+ +const maxImageURLCompatBytes int64 = 40 * 1024 * 1024
48
+ +
49
+ +type imageURLCompatDownloadFunc func(string, ...string) (*http.Response, error)
50
+ +
51
+ +type imageURLCompatStats struct {
52
+ + Converted int
53
+ + DownloadedBytes int64
54
+ + DownloadTime time.Duration
55
+ + EncodeTime time.Duration
56
+ + TotalTime time.Duration
57
+ +}
58
+ +
59
+ +func (s imageURLCompatStats) ServerTiming() string {
60
+ + return fmt.Sprintf(
61
+ + "image-url-download;dur=%.3f, image-b64-encode;dur=%.3f, image-url-compat;dur=%.3f",
62
+ + float64(s.DownloadTime.Microseconds())/1000,
63
+ + float64(s.EncodeTime.Microseconds())/1000,
64
+ + float64(s.TotalTime.Microseconds())/1000,
65
+ + )
66
+ +}
67
+ +
68
+ +func (s imageURLCompatStats) LogMessage() string {
69
+ + return fmt.Sprintf(
70
+ + "image URL compatibility converted=%d bytes=%d download_ms=%.3f encode_ms=%.3f total_ms=%.3f",
71
+ + s.Converted,
72
+ + s.DownloadedBytes,
73
+ + float64(s.DownloadTime.Microseconds())/1000,
74
+ + float64(s.EncodeTime.Microseconds())/1000,
75
+ + float64(s.TotalTime.Microseconds())/1000,
76
+ + )
77
+ +}
78
+ +
79
+ +func convertOpenAIImageURLsToBase64(responseBody []byte, download imageURLCompatDownloadFunc) ([]byte, imageURLCompatStats, error) {
80
+ + var stats imageURLCompatStats
81
+ + if !common.GetEnvOrDefaultBool("CODEX_IMAGE_URL_TO_B64", false) {
82
+ + return responseBody, stats, nil
83
+ + }
84
+ +
85
+ + startedAt := time.Now()
86
+ + var payload map[string]json.RawMessage
87
+ + if err := common.Unmarshal(responseBody, &payload); err != nil {
88
+ + return nil, stats, fmt.Errorf("decode image compatibility response: %w", err)
89
+ + }
90
+ + dataJSON, ok := payload["data"]
91
+ + if !ok {
92
+ + return responseBody, stats, nil
93
+ + }
94
+ +
95
+ + var images []map[string]json.RawMessage
96
+ + if err := common.Unmarshal(dataJSON, &images); err != nil {
97
+ + return nil, stats, fmt.Errorf("decode image compatibility data: %w", err)
98
+ + }
99
+ +
100
+ + for _, image := range images {
101
+ + if jsonString(image["b64_json"]) != "" {
102
+ + continue
103
+ + }
104
+ +
105
+ + var imageURL string
106
+ + var sourceField string
107
+ + for _, field := range []string{"url", "result_url", "image_url"} {
108
+ + if value := jsonString(image[field]); value != "" {
109
+ + imageURL = value
110
+ + sourceField = field
111
+ + break
112
+ + }
113
+ + }
114
+ + if imageURL == "" {
115
+ + continue
116
+ + }
117
+ +
118
+ + downloadStartedAt := time.Now()
119
+ + imageResponse, err := download(imageURL, "OpenAI image URL compatibility")
120
+ + if err != nil {
121
+ + return nil, stats, fmt.Errorf("download image compatibility URL: %w", err)
122
+ + }
123
+ + imageBytes, readErr := readImageURLCompatResponse(imageResponse)
124
+ + stats.DownloadTime += time.Since(downloadStartedAt)
125
+ + if readErr != nil {
126
+ + return nil, stats, readErr
127
+ + }
128
+ +
129
+ + encodeStartedAt := time.Now()
130
+ + encodedJSON, err := common.Marshal(base64.StdEncoding.EncodeToString(imageBytes))
131
+ + if err != nil {
132
+ + return nil, stats, fmt.Errorf("encode image compatibility response: %w", err)
133
+ + }
134
+ + image["b64_json"] = encodedJSON
135
+ + delete(image, sourceField)
136
+ + stats.EncodeTime += time.Since(encodeStartedAt)
137
+ + stats.Converted++
138
+ + stats.DownloadedBytes += int64(len(imageBytes))
139
+ + }
140
+ +
141
+ + if stats.Converted == 0 {
142
+ + return responseBody, stats, nil
143
+ + }
144
+ +
145
+ + encodeStartedAt := time.Now()
146
+ + dataJSON, err := common.Marshal(images)
147
+ + if err != nil {
148
+ + return nil, stats, fmt.Errorf("encode image compatibility data: %w", err)
149
+ + }
150
+ + payload["data"] = dataJSON
151
+ + convertedBody, err := common.Marshal(payload)
152
+ + stats.EncodeTime += time.Since(encodeStartedAt)
153
+ + stats.TotalTime = time.Since(startedAt)
154
+ + if err != nil {
155
+ + return nil, stats, fmt.Errorf("encode image compatibility payload: %w", err)
156
+ + }
157
+ + return convertedBody, stats, nil
158
+ +}
159
+ +
160
+ +func jsonString(raw json.RawMessage) string {
161
+ + if len(raw) == 0 {
162
+ + return ""
163
+ + }
164
+ + var value string
165
+ + if err := common.Unmarshal(raw, &value); err != nil {
166
+ + return ""
167
+ + }
168
+ + return strings.TrimSpace(value)
169
+ +}
170
+ +
171
+ +func readImageURLCompatResponse(response *http.Response) ([]byte, error) {
172
+ + if response == nil || response.Body == nil {
173
+ + return nil, fmt.Errorf("image compatibility download returned an empty response")
174
+ + }
175
+ + defer response.Body.Close()
176
+ + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
177
+ + return nil, fmt.Errorf("image compatibility download returned HTTP %d", response.StatusCode)
178
+ + }
179
+ + if response.ContentLength > maxImageURLCompatBytes {
180
+ + return nil, fmt.Errorf("image compatibility download exceeds %d bytes", maxImageURLCompatBytes)
181
+ + }
182
+ +
183
+ + imageBytes, err := io.ReadAll(io.LimitReader(response.Body, maxImageURLCompatBytes+1))
184
+ + if err != nil {
185
+ + return nil, fmt.Errorf("read image compatibility download: %w", err)
186
+ + }
187
+ + if int64(len(imageBytes)) > maxImageURLCompatBytes {
188
+ + return nil, fmt.Errorf("image compatibility download exceeds %d bytes", maxImageURLCompatBytes)
189
+ + }
190
+ + if len(imageBytes) == 0 {
191
+ + return nil, fmt.Errorf("image compatibility download returned an empty body")
192
+ + }
193
+ + return imageBytes, nil
194
+ +}
195
+ diff --git a/relay/channel/openai/image_url_compat_test.go b/relay/channel/openai/image_url_compat_test.go
196
+ new file mode 100644
197
+ index 0000000..2722bba
198
+ --- /dev/null
199
+ +++ b/relay/channel/openai/image_url_compat_test.go
200
+ @@ -0,0 +1,70 @@
201
+ +package openai
202
+ +
203
+ +import (
204
+ + "bytes"
205
+ + "io"
206
+ + "net/http"
207
+ + "testing"
208
+ +
209
+ + "github.com/QuantumNous/new-api/common"
210
+ + "github.com/stretchr/testify/assert"
211
+ + "github.com/stretchr/testify/require"
212
+ +)
213
+ +
214
+ +func TestConvertOpenAIImageURLsToBase64Disabled(t *testing.T) {
215
+ + t.Setenv("CODEX_IMAGE_URL_TO_B64", "false")
216
+ + body := []byte(`{"created":1,"data":[{"url":"https://example.com/image.png"}]}`)
217
+ +
218
+ + converted, stats, err := convertOpenAIImageURLsToBase64(body, func(string, ...string) (*http.Response, error) {
219
+ + t.Fatal("download must not run while compatibility is disabled")
220
+ + return nil, nil
221
+ + })
222
+ +
223
+ + require.NoError(t, err)
224
+ + assert.Equal(t, body, converted)
225
+ + assert.Zero(t, stats.Converted)
226
+ +}
227
+ +
228
+ +func TestConvertOpenAIImageURLsToBase64PreservesResponseFields(t *testing.T) {
229
+ + t.Setenv("CODEX_IMAGE_URL_TO_B64", "true")
230
+ + image := []byte("test-image")
231
+ + body := []byte(`{"created":1,"data":[{"url":"https://example.com/image.png","revised_prompt":"kept"}],"usage":{"total_tokens":7},"custom":{"kept":true}}`)
232
+ +
233
+ + converted, stats, err := convertOpenAIImageURLsToBase64(body, func(rawURL string, reason ...string) (*http.Response, error) {
234
+ + assert.Equal(t, "https://example.com/image.png", rawURL)
235
+ + assert.Equal(t, []string{"OpenAI image URL compatibility"}, reason)
236
+ + return &http.Response{
237
+ + StatusCode: http.StatusOK,
238
+ + ContentLength: int64(len(image)),
239
+ + Body: io.NopCloser(bytes.NewReader(image)),
240
+ + }, nil
241
+ + })
242
+ +
243
+ + require.NoError(t, err)
244
+ + assert.Equal(t, 1, stats.Converted)
245
+ + assert.Equal(t, int64(len(image)), stats.DownloadedBytes)
246
+ +
247
+ + var payload map[string]any
248
+ + require.NoError(t, common.Unmarshal(converted, &payload))
249
+ + data := payload["data"].([]any)
250
+ + item := data[0].(map[string]any)
251
+ + assert.Equal(t, "dGVzdC1pbWFnZQ==", item["b64_json"])
252
+ + assert.Equal(t, "kept", item["revised_prompt"])
253
+ + assert.NotContains(t, item, "url")
254
+ + assert.Equal(t, float64(7), payload["usage"].(map[string]any)["total_tokens"])
255
+ + assert.Equal(t, true, payload["custom"].(map[string]any)["kept"])
256
+ + assert.Contains(t, stats.ServerTiming(), "image-url-compat")
257
+ +}
258
+ +
259
+ +func TestReadImageURLCompatResponseRejectsOversize(t *testing.T) {
260
+ + response := &http.Response{
261
+ + StatusCode: http.StatusOK,
262
+ + ContentLength: maxImageURLCompatBytes + 1,
263
+ + Body: io.NopCloser(bytes.NewReader([]byte("unused"))),
264
+ + }
265
+ +
266
+ + _, err := readImageURLCompatResponse(response)
267
+ +
268
+ + require.Error(t, err)
269
+ + assert.Contains(t, err.Error(), "exceeds")
270
+ +}
271
+ diff --git a/relay/channel/openai/relay_image.go b/relay/channel/openai/relay_image.go
272
+ index e0f09aa..a6cfad1 100644
273
+ --- a/relay/channel/openai/relay_image.go
274
+ +++ b/relay/channel/openai/relay_image.go
275
+ @@ -39,6 +39,16 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
276
+ return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
277
+ }
278
+
279
+ + responseBody, compatStats, err := convertOpenAIImageURLsToBase64(responseBody, service.DoDownloadRequest)
280
+ + if err != nil {
281
+ + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
282
+ + }
283
+ + if compatStats.Converted > 0 {
284
+ + c.Header("Server-Timing", compatStats.ServerTiming())
285
+ + c.Header("X-New-API-Image-Compat", "url-to-b64")
286
+ + logger.LogInfo(c, compatStats.LogMessage())
287
+ + }
288
+ +
289
+ var usageResp dto.SimpleResponse
290
+ err = common.Unmarshal(responseBody, &usageResp)
291
+ if err != nil {