feat(images): 账户开关将生图结果 url 下载回填为 b64_json

部分第三方 GPT-image 上游把 Images 响应里的 b64_json 换成 url,按官方接口实现的
客户端读不到图。账户 extra.images_url_to_b64_json 开启后,OpenAI API Key 的
Images 非流式响应里 b64_json 缺失或为空且 url 非空的项,由网关下载 url 内容并以
base64 回填 b64_json,url 与其余字段原样保留;异步生图任务复用同一路径。

- 下载经账户代理发起,沿用 base_url 的出站 URL 校验策略(validateUpstreamBaseURL
  抽出 validateOutboundURL,nil cfg 按仅格式校验处理),单图上限 20MB 且须为图片
  内容(响应头或字节嗅探);data: URL 直接取 base64 载荷
- 单项下载失败仅记日志并保留原样;客户端显式 response_format=url 时不回填;
  流式响应不处理
- 创建/编辑账户弹窗在 OpenAI API Key 的 Responses 模式块后新增“生图结果 URL 转
  base64”开关,中英文案;新增 openai_images_b64_backfill_test.go
- 只被 unit 标签测试引用的定价目录测试辅助补上 //go:build unit(admin 包整文件加标签,
  service 包的 newPricingServiceFromModelPricing 搬到 pricing_stub_helpers_unit_test.go),
  消除 golangci-lint 在默认标签下的 unused 报错
This commit is contained in:
feeeei
2026-09-05 15:17:57 +08:00
parent 85f431a072
commit 3c53ba01a7
10 changed files with 629 additions and 14 deletions
@@ -18,24 +18,28 @@ import (
)
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
if err != nil {
return "", fmt.Errorf("invalid base_url: %w", err)
}
return normalized, nil
}
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
RequireAllowlist: true,
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
})
normalized, err := s.validateOutboundURL(raw)
if err != nil {
return "", fmt.Errorf("invalid base_url: %w", err)
}
return normalized, nil
}
// validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。
func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) {
if s.cfg == nil {
return urlvalidator.ValidateURLFormat(raw, false)
}
if !s.cfg.Security.URLAllowlist.Enabled {
return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
}
return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
RequireAllowlist: true,
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
})
}
// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
// - base 以 /v1 结尾:追加 /responses
// - base 以其他版本段结尾(如 /v4):追加 /responses
+9 -2
View File
@@ -722,7 +722,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
ImageOutputSizes: imageOutputSizes,
}, nil
} else {
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, account, parsed)
if err != nil {
return nil, err
}
@@ -894,11 +894,18 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
return dst
}
func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) {
func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
parsed *OpenAIImagesRequest,
) (OpenAIUsage, int, []string, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return OpenAIUsage{}, 0, nil, err
}
body = s.backfillOpenAIImagesB64JSON(ctx, account, parsed, body)
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json"
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
@@ -0,0 +1,151 @@
package service
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// AccountExtraImagesURLToB64JSON 是账户 extra 中的开关键。开启后,Images 端点的非流式响应里
// b64_json 缺失或为空但 url 非空的图片项,由网关下载 url 内容并以 base64 回填到 b64_json。
const AccountExtraImagesURLToB64JSON = "images_url_to_b64_json"
// openAIImageURLDownloadTimeout 是单张图片 url 下载的超时上限。
const openAIImageURLDownloadTimeout = 60 * time.Second
// ImagesURLToB64JSONEnabled 返回账户是否开启了 url 转 b64_json 回填。
func ImagesURLToB64JSONEnabled(account *Account) bool {
return account != nil && account.getExtraBool(AccountExtraImagesURLToB64JSON)
}
// backfillOpenAIImagesB64JSON 对 Images 端点的非流式响应做 url 转 b64_json 回填。
//
// 只处理 data[i].b64_json 缺失或为空且 url 非空的项;url 字段原样保留。
// 单项失败仅记日志并保留该项原样,响应整体照常返回。
// 客户端显式要求 response_format=url 时不做回填。
func (s *OpenAIGatewayService) backfillOpenAIImagesB64JSON(
ctx context.Context,
account *Account,
parsed *OpenAIImagesRequest,
body []byte,
) []byte {
if !ImagesURLToB64JSONEnabled(account) {
return body
}
if parsed != nil && parsed.ResponseFormat == "url" {
return body
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return body
}
items := gjson.GetBytes(body, "data")
if !items.IsArray() {
return body
}
for index, item := range items.Array() {
if !item.IsObject() {
continue
}
if strings.TrimSpace(item.Get("b64_json").String()) != "" {
continue
}
rawURL := strings.TrimSpace(item.Get("url").String())
if rawURL == "" {
continue
}
encoded, err := s.fetchOpenAIImageURLBase64(ctx, account, rawURL)
if err != nil {
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s",
account.ID,
index,
sanitizeUpstreamErrorMessage(err.Error()),
)
continue
}
updated, err := sjson.SetBytes(body, fmt.Sprintf("data.%d.b64_json", index), encoded)
if err != nil {
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s",
account.ID,
index,
sanitizeUpstreamErrorMessage(err.Error()),
)
continue
}
body = updated
}
return body
}
// fetchOpenAIImageURLBase64 取得图片 url 内容的标准 base64 编码。
// data: 形式的 url 直接取其 base64 载荷;其余 url 沿用 base_url 的出站 URL 策略校验后,
// 经账户代理下载,大小上限与 OAuth 路径的单图下载一致,且必须是图片内容。
func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, account *Account, rawURL string) (string, error) {
if strings.HasPrefix(strings.ToLower(rawURL), "data:") {
if encoded := normalizeOpenAIImageBase64(rawURL); encoded != "" {
return encoded, nil
}
return "", errors.New("data url payload is not valid base64")
}
if s == nil || s.httpUpstream == nil {
return "", errors.New("http upstream is not configured")
}
downloadURL, err := s.validateOutboundURL(rawURL)
if err != nil {
return "", fmt.Errorf("invalid image url: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, openAIImageURLDownloadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", fmt.Errorf("build image download request: %w", err)
}
req.Header.Set("Accept", "image/*,*/*;q=0.8")
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return "", fmt.Errorf("download image: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes+1))
if err != nil {
return "", fmt.Errorf("read image body: %w", err)
}
if int64(len(data)) > openAIImageMaxDownloadBytes {
return "", fmt.Errorf("downloaded image exceeds %d bytes", openAIImageMaxDownloadBytes)
}
if len(data) == 0 {
return "", errors.New("download image: empty body")
}
if !isImageContent(resp.Header.Get("Content-Type"), data) {
return "", errors.New("download image: content is not an image")
}
return base64.StdEncoding.EncodeToString(data), nil
}
// isImageContent 由响应头声明或字节嗅探判定内容是否为图片。
func isImageContent(contentType string, data []byte) bool {
declared := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if strings.HasPrefix(declared, "image/") {
return true
}
return detectedImageContentType(data) != ""
}
@@ -0,0 +1,311 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。
var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response {
header := http.Header{}
if contentType != "" {
header.Set("Content-Type", contentType)
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewReader(payload)),
}
}
func b64BackfillAccount(enabled bool) *Account {
account := &Account{
ID: 7,
Name: "openai-apikey",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://relay.example.com/v1",
},
}
if enabled {
account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true}
}
return account
}
func TestImagesURLToB64JSONEnabled(t *testing.T) {
require.False(t, ImagesURLToB64JSONEnabled(nil))
require.False(t, ImagesURLToB64JSONEnabled(&Account{}))
require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}}))
require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}}))
require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}}))
}
func TestBackfillOpenAIImagesB64JSON(t *testing.T) {
wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes)
tests := []struct {
name string
enabled bool
parsed *OpenAIImagesRequest
body string
upstream *httpUpstreamRecorder
wantB64 []string
wantDownloads int
}{
{
name: "开关关闭时原样返回",
enabled: false,
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{""},
wantDownloads: 0,
},
{
name: "缺少 b64_json 且有 url 时下载回填并保留 url",
enabled: true,
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{wantB64},
wantDownloads: 1,
},
{
name: "b64_json 为空串时同样回填",
enabled: true,
body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{wantB64},
wantDownloads: 1,
},
{
name: "b64_json 已有值时不下载",
enabled: true,
body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{"aW1n"},
wantDownloads: 0,
},
{
name: "多项混合时只回填缺失项",
enabled: true,
body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{"aW1n", wantB64, ""},
wantDownloads: 1,
},
{
name: "data URL 直接取载荷不下载",
enabled: true,
body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`,
upstream: &httpUpstreamRecorder{},
wantB64: []string{wantB64},
wantDownloads: 0,
},
{
name: "客户端显式要求 url 时不回填",
enabled: true,
parsed: &OpenAIImagesRequest{ResponseFormat: "url"},
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{""},
wantDownloads: 0,
},
{
name: "下载非 2xx 时保留原样",
enabled: true,
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))},
wantB64: []string{""},
wantDownloads: 1,
},
{
name: "下载内容不是图片时保留原样",
enabled: true,
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("<html>login required</html>"))},
wantB64: []string{""},
wantDownloads: 1,
},
{
name: "响应头未声明图片类型但字节嗅探为图片时回填",
enabled: true,
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)},
wantB64: []string{wantB64},
wantDownloads: 1,
},
{
name: "非 http(s) 协议的 url 不下载",
enabled: true,
body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`,
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)},
wantB64: []string{""},
wantDownloads: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream}
got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body))
require.True(t, gjson.ValidBytes(got))
items := gjson.GetBytes(got, "data").Array()
require.Len(t, items, len(tt.wantB64))
for i, want := range tt.wantB64 {
require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i)
}
require.Len(t, tt.upstream.requests, tt.wantDownloads)
// 除 b64_json 外的字段必须原样保留。
original := gjson.Parse(tt.body)
original.Get("data").ForEach(func(key, item gjson.Result) bool {
item.ForEach(func(field, value gjson.Result) bool {
if field.String() == "b64_json" {
return true
}
require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw)
return true
})
return true
})
require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw)
})
}
}
func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := b64BackfillAccount(true)
proxyID := int64(3)
account.ProxyID = &proxyID
account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890}
body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`)
got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body)
require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String())
require.Len(t, upstream.requests, 1)
req := upstream.requests[0]
require.Equal(t, http.MethodGet, req.Method)
require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String())
require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL)
_, hasDeadline := req.Context().Deadline()
require.True(t, hasDeadline)
}
func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := b64BackfillAccount(true)
for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} {
got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body))
require.Equal(t, body, string(got), "body=%q", body)
}
require.Empty(t, upstream.requests)
}
func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("api_key", &APIKey{ID: 42})
svc := &OpenAIGatewayService{cfg: &config.Config{}}
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
upstream := &httpUpstreamRecorder{
responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"req_img_url"},
},
Body: io.NopCloser(strings.NewReader(
`{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`,
)),
},
b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes),
},
}
svc.httpUpstream = upstream
result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, result.ImageCount)
require.False(t, result.Stream)
require.Len(t, upstream.requests, 2)
require.Equal(t, http.MethodPost, upstream.requests[0].Method)
require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String())
require.Equal(t, http.MethodGet, upstream.requests[1].Method)
require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String())
require.Equal(t, http.StatusOK, rec.Code)
wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes)
require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String())
require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String())
require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String())
require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int())
}
func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("api_key", &APIKey{ID: 42})
svc := &OpenAIGatewayService{cfg: &config.Config{}}
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
require.NoError(t, err)
upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}`
upstream := &httpUpstreamRecorder{
responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
},
b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes),
},
}
svc.httpUpstream = upstream
result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, result.ImageCount)
require.Len(t, upstream.requests, 1)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, upstreamBody, rec.Body.String())
}
@@ -3313,6 +3313,37 @@
</div>
</div>
<!-- OpenAI APIKey images: backfill b64_json from url -->
<div
v-if="form.platform === 'openai' && accountCategory === 'apikey'"
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.imagesUrlToB64Json') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.imagesUrlToB64JsonDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-images-url-to-b64-json-toggle"
role="switch"
:aria-checked="openAIImagesUrlToB64JsonEnabled"
@click="openAIImagesUrlToB64JsonEnabled = !openAIImagesUrlToB64JsonEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAIImagesUrlToB64JsonEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAIImagesUrlToB64JsonEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div>
<div class="flex items-center justify-between">
<div>
@@ -4246,6 +4277,8 @@ const openAILongContextBillingEnabled = ref(false)
const openAILongContextBillingTouched = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
// Images 非流式响应缺 b64_json 时由网关下载 url 回填(仅 OpenAI API Key)。
const openAIImagesUrlToB64JsonEnabled = ref(false)
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
@@ -4724,6 +4757,7 @@ watch(
// 避免上一平台的配置行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
openAIImagesUrlToB64JsonEnabled.value = false
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
// Reset OAuth states
@@ -5149,6 +5183,7 @@ const resetForm = () => {
customErrorCodeInput.value = null
headerOverrideEnabled.value = false
headerOverrideRows.value = []
openAIImagesUrlToB64JsonEnabled.value = false
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
interceptWarmupRequests.value = false
@@ -5286,6 +5321,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
} else {
delete extra.openai_responses_mode
}
if (accountCategory.value === 'apikey' && openAIImagesUrlToB64JsonEnabled.value) {
extra.images_url_to_b64_json = true
} else {
delete extra.images_url_to_b64_json
}
return Object.keys(extra).length > 0 ? extra : undefined
}
@@ -1821,6 +1821,37 @@
</div>
</div>
<!-- OpenAI APIKey images: backfill b64_json from url -->
<div
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.imagesUrlToB64Json') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.imagesUrlToB64JsonDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-images-url-to-b64-json-toggle"
role="switch"
:aria-checked="openAIImagesUrlToB64JsonEnabled"
@click="openAIImagesUrlToB64JsonEnabled = !openAIImagesUrlToB64JsonEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAIImagesUrlToB64JsonEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAIImagesUrlToB64JsonEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div
v-if="account?.type === 'apikey'"
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -3282,6 +3313,8 @@ const openAILongContextBillingEnabled = ref(false)
const editPlanType = ref<string>('')
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
// Images 非流式响应缺 b64_json 时由网关下载 url 回填(仅 OpenAI API Key)。
const openAIImagesUrlToB64JsonEnabled = ref(false)
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
@@ -3742,6 +3775,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
mixedScheduling.value = extra?.mixed_scheduling === true
allowOverages.value = extra?.allow_overages === true
upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra)
openAIImagesUrlToB64JsonEnabled.value = extra?.images_url_to_b64_json === true
autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
@@ -5214,6 +5248,11 @@ const handleSubmit = async () => {
delete newExtra.openai_responses_mode
} else {
newExtra.openai_responses_mode = openAIResponsesMode.value
}
if (openAIImagesUrlToB64JsonEnabled.value) {
newExtra.images_url_to_b64_json = true
} else {
delete newExtra.images_url_to_b64_json
}
}
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {
@@ -268,6 +268,27 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id')
})
it('omits images_url_to_b64_json from extra by default', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('images_url_to_b64_json')
})
it('sends images_url_to_b64_json in extra when the toggle is enabled', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]').trigger('click')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.images_url_to_b64_json).toBe(true)
})
it('persists upstream model metadata after creating an account from preview', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
@@ -718,6 +718,42 @@ describe('EditAccountModal', () => {
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header')
})
it('writes images_url_to_b64_json into extra when toggled on', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('false')
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.images_url_to_b64_json).toBe(true)
})
it('removes images_url_to_b64_json from extra when toggled off', async () => {
const account = buildAccount()
account.extra = { images_url_to_b64_json: true }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('true')
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined()
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('images_url_to_b64_json')
})
it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => {
const account = buildAccount()
const wrapper = mountModal(account)
@@ -611,6 +611,9 @@ export default {
responsesModeForceChatCompletions: 'Force Chat Completions',
responsesModeTextDisabledHint:
'Not applicable when the Responses / Chat Completions endpoint is not enabled.',
imagesUrlToB64Json: 'Image result URL to base64',
imagesUrlToB64JsonDesc:
'Only applies to non-streaming Images responses of OpenAI API Key accounts. When an upstream image item has a url but no b64_json, the gateway downloads the url and fills b64_json with its base64 content (url is kept) for clients built on the official API; the response is returned unchanged if the download fails.',
endpointCapabilities: 'Endpoint capabilities',
endpointCapabilitiesDesc:
'Used by account routing. The text endpoint follows the Responses API support setting above and is shown as Responses, Chat Completions, or auto mode; Embeddings independently controls /v1/embeddings.',
@@ -692,6 +692,9 @@ export default {
responsesModeForceResponses: '强制 Responses',
responsesModeForceChatCompletions: '强制 Chat Completions',
responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。',
imagesUrlToB64Json: '生图结果 URL 转 base64',
imagesUrlToB64JsonDesc:
'仅对 OpenAI API Key 的 Images 非流式响应生效。上游返回的图片缺少 b64_json 但带 url 时,网关下载该 url 并以 base64 回填 b64_json(url 保留),兼容按官方接口实现的客户端;下载失败则原样返回。',
endpointCapabilities: '端点能力',
endpointCapabilitiesDesc:
'用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。',