mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-12 14:41:42 +00:00
Merge pull request #6638 from feeeei/feat/images-url-to-b64json-backfill
feat(images): 账户开关将生图结果 url 下载回填为 b64_json
This commit is contained in:
16 files changed
+852
-14
No files matched your search
@@ -213,7 +213,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 执行请求
|
// 执行请求
|
||||||
client := httpClientForUpstreamRequest(entry.client, req)
|
client := s.httpClientForUpstreamRequest(entry.client, req)
|
||||||
client = httpClientWithGrokAccessDeniedFallback(client)
|
client = httpClientWithGrokAccessDeniedFallback(client)
|
||||||
resp, err := servertiming.Do(client, req)
|
resp, err := servertiming.Do(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,7 +277,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := httpClientForUpstreamRequest(entry.client, req)
|
client := s.httpClientForUpstreamRequest(entry.client, req)
|
||||||
client = httpClientWithGrokAccessDeniedFallback(client)
|
client = httpClientWithGrokAccessDeniedFallback(client)
|
||||||
resp, err := servertiming.Do(client, req)
|
resp, err := servertiming.Do(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,15 +297,27 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client {
|
// httpClientForUpstreamRequest 按请求上下文的标记派生客户端:禁用重定向,或对重定向的每一跳做主机校验。
|
||||||
if client == nil || req == nil || !service.HTTPUpstreamRedirectsDisabled(req.Context()) {
|
// 派生的克隆与缓存客户端共享 Transport;未打标记时原样返回。
|
||||||
|
func (s *httpUpstreamService) httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client {
|
||||||
|
if client == nil || req == nil {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
ctx := req.Context()
|
||||||
|
switch {
|
||||||
|
case service.HTTPUpstreamRedirectsDisabled(ctx):
|
||||||
clone := *client
|
clone := *client
|
||||||
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
|
clone.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
}
|
}
|
||||||
return &clone
|
return &clone
|
||||||
|
case service.HTTPUpstreamPublicHostsOnly(ctx) && client.CheckRedirect == nil:
|
||||||
|
clone := *client
|
||||||
|
clone.CheckRedirect = s.redirectChecker
|
||||||
|
return &clone
|
||||||
|
default:
|
||||||
|
return client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as
|
// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as
|
||||||
@@ -587,8 +599,11 @@ func (s *httpUpstreamService) shouldValidateResolvedIP() bool {
|
|||||||
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
|
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateRequestHost 校验请求主机的解析结果不落在回环、私网、链路本地或未指定地址。
|
||||||
|
// 是否全局启用由 security.url_allowlist 决定;带 WithHTTPUpstreamPublicHostsOnly 标记的请求无论配置如何都校验。
|
||||||
func (s *httpUpstreamService) validateRequestHost(req *http.Request) error {
|
func (s *httpUpstreamService) validateRequestHost(req *http.Request) error {
|
||||||
if !s.shouldValidateResolvedIP() {
|
publicHostsOnly := req != nil && service.HTTPUpstreamPublicHostsOnly(req.Context())
|
||||||
|
if !s.shouldValidateResolvedIP() && !publicHostsOnly {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if req == nil || req.URL == nil {
|
if req == nil || req.URL == nil {
|
||||||
|
|||||||
@@ -961,3 +961,63 @@ func hasEntry(svc *httpUpstreamService, target *upstreamClientEntry) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPUpstreamDoPublicHostsOnlyRejectsPrivateDestinationBeforeConnecting(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(target.Close)
|
||||||
|
|
||||||
|
upstream := NewHTTPUpstream(nil)
|
||||||
|
|
||||||
|
plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := upstream.Do(plain, "", 1, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, int64(1), calls.Load(), "loopback stays reachable for requests without the marker")
|
||||||
|
|
||||||
|
guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, target.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err = upstream.Do(guarded, "", 1, 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Contains(t, err.Error(), "not allowed")
|
||||||
|
require.Equal(t, int64(1), calls.Load(), "marked request must be rejected before any connection is made")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPUpstreamPublicHostsOnlyValidatesEveryRedirectHop(t *testing.T) {
|
||||||
|
upstream, ok := NewHTTPUpstream(nil).(*httpUpstreamService)
|
||||||
|
require.True(t, ok)
|
||||||
|
base := &http.Client{}
|
||||||
|
|
||||||
|
plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/a.png", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Same(t, base, upstream.httpClientForUpstreamRequest(base, plain))
|
||||||
|
|
||||||
|
guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, "https://cdn.example.com/a.png", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
client := upstream.httpClientForUpstreamRequest(base, guarded)
|
||||||
|
require.NotSame(t, base, client)
|
||||||
|
require.NotNil(t, client.CheckRedirect)
|
||||||
|
require.Nil(t, base.CheckRedirect, "the cached client must stay untouched")
|
||||||
|
|
||||||
|
via := []*http.Request{guarded}
|
||||||
|
for _, hop := range []string{
|
||||||
|
"http://127.0.0.1:8080/a.png",
|
||||||
|
"http://[::1]:8080/a.png",
|
||||||
|
"http://10.0.0.8/a.png",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://0.0.0.0/a.png",
|
||||||
|
} {
|
||||||
|
hopReq, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, hop, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, client.CheckRedirect(hopReq, via), "hop=%s", hop)
|
||||||
|
}
|
||||||
|
publicHop, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, "http://93.184.216.34/a.png", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, client.CheckRedirect(publicHop, via))
|
||||||
|
require.Error(t, client.CheckRedirect(publicHop, make([]*http.Request, 10)), "redirect chain stays capped")
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
|
|
||||||
type httpUpstreamProfileContextKey struct{}
|
type httpUpstreamProfileContextKey struct{}
|
||||||
type httpUpstreamDisableRedirectsContextKey struct{}
|
type httpUpstreamDisableRedirectsContextKey struct{}
|
||||||
|
type httpUpstreamPublicHostsOnlyContextKey struct{}
|
||||||
|
|
||||||
// WithHTTPUpstreamProfile injects an upstream transport profile into ctx.
|
// WithHTTPUpstreamProfile injects an upstream transport profile into ctx.
|
||||||
func WithHTTPUpstreamProfile(ctx context.Context, profile HTTPUpstreamProfile) context.Context {
|
func WithHTTPUpstreamProfile(ctx context.Context, profile HTTPUpstreamProfile) context.Context {
|
||||||
@@ -55,3 +56,18 @@ func WithHTTPUpstreamRedirectsDisabled(ctx context.Context) context.Context {
|
|||||||
func HTTPUpstreamRedirectsDisabled(ctx context.Context) bool {
|
func HTTPUpstreamRedirectsDisabled(ctx context.Context) bool {
|
||||||
return ctx != nil && ctx.Value(httpUpstreamDisableRedirectsContextKey{}) == true
|
return ctx != nil && ctx.Value(httpUpstreamDisableRedirectsContextKey{}) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithHTTPUpstreamPublicHostsOnly marks a request whose destination, and every
|
||||||
|
// redirect hop after it, must resolve to a public address. The shared upstream
|
||||||
|
// client enforces it regardless of the security.url_allowlist configuration;
|
||||||
|
// use it for fetches whose URL comes from an untrusted upstream response.
|
||||||
|
func WithHTTPUpstreamPublicHostsOnly(ctx context.Context) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, httpUpstreamPublicHostsOnlyContextKey{}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTTPUpstreamPublicHostsOnly(ctx context.Context) bool {
|
||||||
|
return ctx != nil && ctx.Value(httpUpstreamPublicHostsOnlyContextKey{}) == true
|
||||||
|
}
|
||||||
@@ -30,3 +30,17 @@ func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) {
|
|||||||
t.Fatal("redirects should remain enabled by default")
|
t.Fatal("redirects should remain enabled by default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithHTTPUpstreamPublicHostsOnly(t *testing.T) {
|
||||||
|
//nolint:staticcheck // Exercises the defensive nil-context fallback.
|
||||||
|
ctx := WithHTTPUpstreamPublicHostsOnly(nil)
|
||||||
|
if !HTTPUpstreamPublicHostsOnly(ctx) {
|
||||||
|
t.Fatal("expected public-hosts-only marker to be set")
|
||||||
|
}
|
||||||
|
if HTTPUpstreamPublicHostsOnly(context.Background()) {
|
||||||
|
t.Fatal("marker must be absent by default")
|
||||||
|
}
|
||||||
|
if HTTPUpstreamRedirectsDisabled(ctx) {
|
||||||
|
t.Fatal("public-hosts-only must not disable redirects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,22 +18,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||||
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
normalized, err := s.validateOutboundURL(raw)
|
||||||
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid base_url: %w", err)
|
return "", fmt.Errorf("invalid base_url: %w", err)
|
||||||
}
|
}
|
||||||
return normalized, nil
|
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)
|
||||||
}
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
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,
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid base_url: %w", err)
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
|
// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
|||||||
ImageOutputSizes: imageOutputSizes,
|
ImageOutputSizes: imageOutputSizes,
|
||||||
}, nil
|
}, nil
|
||||||
} else {
|
} else {
|
||||||
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
|
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, account, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -894,11 +894,18 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
|
|||||||
return dst
|
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)
|
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OpenAIUsage{}, 0, nil, err
|
return OpenAIUsage{}, 0, nil, err
|
||||||
}
|
}
|
||||||
|
body = s.backfillOpenAIImagesB64JSON(ctx, account, parsed, body)
|
||||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||||
contentType := "application/json"
|
contentType := "application/json"
|
||||||
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||||
|
"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 路径的单图下载一致,且前 512 字节须嗅探为 png/jpeg/webp/gif。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := rejectPrivateImageHost(downloadURL); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(WithHTTPUpstreamPublicHostsOnly(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 !isBackfillImageContent(data) {
|
||||||
|
return "", errors.New("download image: content is not an allowed image format")
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectPrivateImageHost 拒绝主机为 localhost 或回环、私网、链路本地、未指定地址字面量的下载 URL。
|
||||||
|
// 不受 security.url_allowlist 配置影响;域名的解析结果与重定向的每一跳由上游客户端按
|
||||||
|
// WithHTTPUpstreamPublicHostsOnly 标记校验。
|
||||||
|
func rejectPrivateImageHost(downloadURL string) error {
|
||||||
|
parsed, err := url.Parse(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid image url: %w", err)
|
||||||
|
}
|
||||||
|
if host := parsed.Hostname(); urlvalidator.IsBlockedHost(host) {
|
||||||
|
return fmt.Errorf("image url host is not allowed: %s", host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIImageBackfillContentTypes 是允许回填的图片格式,以字节嗅探结果为准,响应头不作为依据。
|
||||||
|
var openAIImageBackfillContentTypes = map[string]struct{}{
|
||||||
|
"image/png": {},
|
||||||
|
"image/jpeg": {},
|
||||||
|
"image/webp": {},
|
||||||
|
"image/gif": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBackfillImageContent 报告 data 的前 512 字节是否嗅探为允许回填的图片格式。
|
||||||
|
func isBackfillImageContent(data []byte) bool {
|
||||||
|
_, ok := openAIImageBackfillContentTypes[detectedImageContentType(data)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
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: "响应头声明图片但字节不是图片时保留原样",
|
||||||
|
enabled: true,
|
||||||
|
body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`,
|
||||||
|
upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`))},
|
||||||
|
wantB64: []string{""},
|
||||||
|
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)
|
||||||
|
// 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。
|
||||||
|
require.True(t, HTTPUpstreamPublicHostsOnly(req.Context()))
|
||||||
|
require.False(t, HTTPUpstreamRedirectsDisabled(req.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) {
|
||||||
|
upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
|
||||||
|
account := b64BackfillAccount(true)
|
||||||
|
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"http://localhost:8080/api/v1/admin/accounts",
|
||||||
|
"http://Foo.LocalHost/a.png",
|
||||||
|
"http://127.0.0.1:8080/a.png",
|
||||||
|
"https://127.0.0.1/a.png",
|
||||||
|
"http://[::1]:8080/a.png",
|
||||||
|
"http://10.0.0.5/a.png",
|
||||||
|
"http://172.16.0.9:9000/bucket/a.png",
|
||||||
|
"http://192.168.1.10:9000/bucket/a.png",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://0.0.0.0:8080/a.png",
|
||||||
|
"http://[fe80::1]/a.png",
|
||||||
|
"http://[::ffff:127.0.0.1]/a.png",
|
||||||
|
} {
|
||||||
|
body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}`
|
||||||
|
got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body))
|
||||||
|
require.Equal(t, body, string(got), "url=%s", rawURL)
|
||||||
|
}
|
||||||
|
require.Empty(t, upstream.requests, "private destinations must never be requested")
|
||||||
|
|
||||||
|
// 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。
|
||||||
|
got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`))
|
||||||
|
require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String())
|
||||||
|
require.Len(t, upstream.requests, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBackfillImageContent(t *testing.T) {
|
||||||
|
webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "png", data: b64BackfillPNGBytes, want: true},
|
||||||
|
{name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true},
|
||||||
|
{name: "webp", data: webp, want: true},
|
||||||
|
{name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true},
|
||||||
|
{name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false},
|
||||||
|
{name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false},
|
||||||
|
{name: "svg 文本", data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: false},
|
||||||
|
{name: "html", data: []byte("<html>login required</html>"), want: false},
|
||||||
|
{name: "json", data: []byte(`{"error":"unauthorized"}`), want: false},
|
||||||
|
{name: "空", data: nil, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, isBackfillImageContent(tt.data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context()))
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -162,6 +162,12 @@ func isAllowedHost(host string, allowlist []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsBlockedHost 报告 host 是否为 localhost、*.localhost,或回环、私网、链路本地、未指定地址的字面量 IP。
|
||||||
|
// 只判断字面量,域名的解析结果由 ValidateResolvedIP 校验。
|
||||||
|
func IsBlockedHost(host string) bool {
|
||||||
|
return isBlockedHost(strings.ToLower(strings.TrimSpace(host)))
|
||||||
|
}
|
||||||
|
|
||||||
func isBlockedHost(host string) bool {
|
func isBlockedHost(host string) bool {
|
||||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -73,3 +73,20 @@ func TestValidateHTTPURL(t *testing.T) {
|
|||||||
t.Fatalf("expected localhost to be blocked when allow_private_hosts is false")
|
t.Fatalf("expected localhost to be blocked when allow_private_hosts is false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBlockedHost(t *testing.T) {
|
||||||
|
for _, host := range []string{
|
||||||
|
"localhost", "LOCALHOST", "foo.localhost", " 127.0.0.1 ",
|
||||||
|
"127.0.0.1", "::1", "10.0.0.5", "172.16.0.1", "192.168.1.1",
|
||||||
|
"169.254.169.254", "0.0.0.0", "::", "fe80::1", "fc00::1", "::ffff:127.0.0.1",
|
||||||
|
} {
|
||||||
|
if !IsBlockedHost(host) {
|
||||||
|
t.Fatalf("expected %q to be blocked", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, host := range []string{"example.com", "cdn.example.com", "93.184.216.34", "8.8.8.8", "2606:4700:4700::1111", ""} {
|
||||||
|
if IsBlockedHost(host) {
|
||||||
|
t.Fatalf("expected %q to be allowed", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3313,6 +3313,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -4246,6 +4277,8 @@ const openAILongContextBillingEnabled = ref(false)
|
|||||||
const openAILongContextBillingTouched = ref(false)
|
const openAILongContextBillingTouched = ref(false)
|
||||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('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 openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
@@ -4724,6 +4757,7 @@ watch(
|
|||||||
// 避免上一平台的配置行被提交到新平台账号
|
// 避免上一平台的配置行被提交到新平台账号
|
||||||
headerOverrideEnabled.value = false
|
headerOverrideEnabled.value = false
|
||||||
headerOverrideRows.value = []
|
headerOverrideRows.value = []
|
||||||
|
openAIImagesUrlToB64JsonEnabled.value = false
|
||||||
grokOAuthCustomBaseUrlEnabled.value = false
|
grokOAuthCustomBaseUrlEnabled.value = false
|
||||||
grokOAuthBaseUrl.value = ''
|
grokOAuthBaseUrl.value = ''
|
||||||
// Reset OAuth states
|
// Reset OAuth states
|
||||||
@@ -5152,6 +5186,7 @@ const resetForm = () => {
|
|||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
headerOverrideEnabled.value = false
|
headerOverrideEnabled.value = false
|
||||||
headerOverrideRows.value = []
|
headerOverrideRows.value = []
|
||||||
|
openAIImagesUrlToB64JsonEnabled.value = false
|
||||||
grokOAuthCustomBaseUrlEnabled.value = false
|
grokOAuthCustomBaseUrlEnabled.value = false
|
||||||
grokOAuthBaseUrl.value = ''
|
grokOAuthBaseUrl.value = ''
|
||||||
interceptWarmupRequests.value = false
|
interceptWarmupRequests.value = false
|
||||||
@@ -5289,6 +5324,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
|||||||
} else {
|
} else {
|
||||||
delete extra.openai_responses_mode
|
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
|
return Object.keys(extra).length > 0 ? extra : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1821,6 +1821,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-if="account?.type === 'apikey'"
|
v-if="account?.type === 'apikey'"
|
||||||
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
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 editPlanType = ref<string>('')
|
||||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('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 openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const openaiAPIKeyResponsesWebSocketV2Mode = 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
|
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||||
allowOverages.value = extra?.allow_overages === true
|
allowOverages.value = extra?.allow_overages === true
|
||||||
upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra)
|
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
|
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
|
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
|
||||||
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
||||||
@@ -5224,6 +5258,11 @@ const handleSubmit = async () => {
|
|||||||
delete newExtra.openai_responses_mode
|
delete newExtra.openai_responses_mode
|
||||||
} else {
|
} else {
|
||||||
newExtra.openai_responses_mode = openAIResponsesMode.value
|
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) {
|
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')
|
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 () => {
|
it('persists upstream model metadata after creating an account from preview', async () => {
|
||||||
const wrapper = mountModal()
|
const wrapper = mountModal()
|
||||||
await selectButtonByText(wrapper, 'OpenAI')
|
await selectButtonByText(wrapper, 'OpenAI')
|
||||||
|
|||||||
@@ -718,6 +718,42 @@ describe('EditAccountModal', () => {
|
|||||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header')
|
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 () => {
|
it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => {
|
||||||
const account = buildAccount()
|
const account = buildAccount()
|
||||||
const wrapper = mountModal(account)
|
const wrapper = mountModal(account)
|
||||||
|
|||||||
@@ -611,6 +611,9 @@ export default {
|
|||||||
responsesModeForceChatCompletions: 'Force Chat Completions',
|
responsesModeForceChatCompletions: 'Force Chat Completions',
|
||||||
responsesModeTextDisabledHint:
|
responsesModeTextDisabledHint:
|
||||||
'Not applicable when the Responses / Chat Completions endpoint is not enabled.',
|
'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',
|
endpointCapabilities: 'Endpoint capabilities',
|
||||||
endpointCapabilitiesDesc:
|
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.',
|
'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',
|
responsesModeForceResponses: '强制 Responses',
|
||||||
responsesModeForceChatCompletions: '强制 Chat Completions',
|
responsesModeForceChatCompletions: '强制 Chat Completions',
|
||||||
responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。',
|
responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。',
|
||||||
|
imagesUrlToB64Json: '生图结果 URL 转 base64',
|
||||||
|
imagesUrlToB64JsonDesc:
|
||||||
|
'仅对 OpenAI API Key 的 Images 非流式响应生效。上游返回的图片缺少 b64_json 但带 url 时,网关下载该 url 并以 base64 回填 b64_json(url 保留),兼容按官方接口实现的客户端;下载失败则原样返回。',
|
||||||
endpointCapabilities: '端点能力',
|
endpointCapabilities: '端点能力',
|
||||||
endpointCapabilitiesDesc:
|
endpointCapabilitiesDesc:
|
||||||
'用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。',
|
'用于调度筛选。文本端点会跟随上方 Responses API 支持显示为 Responses、Chat Completions 或自动模式;Embeddings 独立控制 /v1/embeddings。',
|
||||||
|
|||||||
Reference in new issue
Block a user