Files
sub2api/backend/internal/service/openai_images.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1648 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
openAIImagesGenerationsEndpoint = "/v1/images/generations"
openAIImagesEditsEndpoint = "/v1/images/edits"
openAIImagesGenerationsURL = "https://api.openai.com/v1/images/generations"
openAIImagesEditsURL = "https://api.openai.com/v1/images/edits"
openAIChatGPTStartURL = "https://chatgpt.com/"
openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files"
openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download
openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part
openAIImagesResponsesMainModel = "gpt-5.4-mini"
)
type OpenAIImagesCapability string
const (
OpenAIImagesCapabilityBasic OpenAIImagesCapability = "images-basic"
OpenAIImagesCapabilityNative OpenAIImagesCapability = "images-native"
)
type OpenAIImagesUpload struct {
FieldName string
FileName string
ContentType string
Data []byte
Width int
Height int
}
type OpenAIImagesRequest struct {
Endpoint string
ContentType string
Multipart bool
Model string
ExplicitModel bool
Prompt string
Stream bool
N int
Size string
ExplicitSize bool
SizeTier string
ResponseFormat string
Quality string
Background string
OutputFormat string
Moderation string
InputFidelity string
Style string
OutputCompression *int
PartialImages *int
HasMask bool
HasNativeOptions bool
RequiredCapability OpenAIImagesCapability
InputImageURLs []string
MaskImageURL string
Uploads []OpenAIImagesUpload
MaskUpload *OpenAIImagesUpload
Body []byte
bodyHash string
}
func (r *OpenAIImagesRequest) ModerationBody() []byte {
if r == nil {
return nil
}
payload := map[string]any{}
if prompt := strings.TrimSpace(r.Prompt); prompt != "" {
payload["prompt"] = prompt
}
images := r.moderationImages()
if len(images) > 0 {
payload["images"] = images
}
if len(payload) == 0 {
return nil
}
body, err := json.Marshal(payload)
if err != nil {
return nil
}
return body
}
func (r *OpenAIImagesRequest) moderationImages() []map[string]string {
if r == nil {
return nil
}
images := make([]map[string]string, 0, len(r.InputImageURLs)+len(r.Uploads)+1)
for _, imageURL := range r.InputImageURLs {
imageURL = strings.TrimSpace(imageURL)
if imageURL != "" {
images = append(images, map[string]string{"image_url": imageURL})
}
}
for _, upload := range r.Uploads {
if dataURL := upload.ModerationDataURL(); dataURL != "" {
images = append(images, map[string]string{"image_url": dataURL})
}
}
if maskURL := strings.TrimSpace(r.MaskImageURL); maskURL != "" {
images = append(images, map[string]string{"image_url": maskURL})
}
if r.MaskUpload != nil {
if dataURL := r.MaskUpload.ModerationDataURL(); dataURL != "" {
images = append(images, map[string]string{"image_url": dataURL})
}
}
return images
}
func (u OpenAIImagesUpload) ModerationDataURL() string {
if len(u.Data) == 0 {
return ""
}
contentType := strings.TrimSpace(u.ContentType)
if contentType == "" {
contentType = http.DetectContentType(u.Data)
}
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
return ""
}
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(u.Data))
}
func (r *OpenAIImagesRequest) IsEdits() bool {
return r != nil && r.Endpoint == openAIImagesEditsEndpoint
}
func (r *OpenAIImagesRequest) StickySessionSeed() string {
if r == nil {
return ""
}
parts := []string{
"openai-images",
strings.TrimSpace(r.Endpoint),
strings.TrimSpace(r.Model),
strings.TrimSpace(r.Size),
strings.TrimSpace(r.Prompt),
}
seed := strings.Join(parts, "|")
if strings.TrimSpace(r.Prompt) == "" && r.bodyHash != "" {
seed += "|body=" + r.bodyHash
}
return seed
}
func (s *OpenAIGatewayService) ParseOpenAIImagesRequest(c *gin.Context, body []byte) (*OpenAIImagesRequest, error) {
if c == nil || c.Request == nil {
return nil, fmt.Errorf("missing request context")
}
endpoint := normalizeOpenAIImagesEndpointPath(c.Request.URL.Path)
if endpoint == "" {
return nil, fmt.Errorf("unsupported images endpoint")
}
contentType := strings.TrimSpace(c.GetHeader("Content-Type"))
req := &OpenAIImagesRequest{
Endpoint: endpoint,
ContentType: contentType,
N: 1,
Body: body,
}
if len(body) > 0 {
sum := sha256.Sum256(body)
req.bodyHash = hex.EncodeToString(sum[:8])
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && strings.EqualFold(mediaType, "multipart/form-data") {
req.Multipart = true
if parseErr := parseOpenAIImagesMultipartRequest(body, contentType, req); parseErr != nil {
return nil, parseErr
}
} else {
if len(body) == 0 {
return nil, fmt.Errorf("request body is empty")
}
if !gjson.ValidBytes(body) {
return nil, fmt.Errorf("failed to parse request body")
}
if parseErr := parseOpenAIImagesJSONRequest(body, req); parseErr != nil {
return nil, parseErr
}
}
applyOpenAIImagesDefaults(req)
if err := validateOpenAIImagesModel(req.Model); err != nil {
return nil, err
}
req.SizeTier = normalizeOpenAIImageSizeTier(req.Size)
req.RequiredCapability = classifyOpenAIImagesCapability(req)
return req, nil
}
func parseOpenAIImagesJSONRequest(body []byte, req *OpenAIImagesRequest) error {
if modelResult := gjson.GetBytes(body, "model"); modelResult.Exists() {
req.Model = strings.TrimSpace(modelResult.String())
req.ExplicitModel = req.Model != ""
}
req.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String())
if streamResult := gjson.GetBytes(body, "stream"); streamResult.Exists() {
if streamResult.Type != gjson.True && streamResult.Type != gjson.False {
return fmt.Errorf("invalid stream field type")
}
req.Stream = streamResult.Bool()
}
if nResult := gjson.GetBytes(body, "n"); nResult.Exists() {
if nResult.Type != gjson.Number {
return fmt.Errorf("invalid n field type")
}
req.N = int(nResult.Int())
if req.N <= 0 {
return fmt.Errorf("n must be greater than 0")
}
}
if sizeResult := gjson.GetBytes(body, "size"); sizeResult.Exists() {
req.Size = strings.TrimSpace(sizeResult.String())
req.ExplicitSize = req.Size != ""
}
req.ResponseFormat = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "response_format").String()))
req.Quality = strings.TrimSpace(gjson.GetBytes(body, "quality").String())
req.Background = strings.TrimSpace(gjson.GetBytes(body, "background").String())
req.OutputFormat = strings.TrimSpace(gjson.GetBytes(body, "output_format").String())
req.Moderation = strings.TrimSpace(gjson.GetBytes(body, "moderation").String())
req.InputFidelity = strings.TrimSpace(gjson.GetBytes(body, "input_fidelity").String())
req.Style = strings.TrimSpace(gjson.GetBytes(body, "style").String())
req.HasMask = gjson.GetBytes(body, "mask").Exists()
if outputCompression := gjson.GetBytes(body, "output_compression"); outputCompression.Exists() {
if outputCompression.Type != gjson.Number {
return fmt.Errorf("invalid output_compression field type")
}
v := int(outputCompression.Int())
req.OutputCompression = &v
}
if partialImages := gjson.GetBytes(body, "partial_images"); partialImages.Exists() {
if partialImages.Type != gjson.Number {
return fmt.Errorf("invalid partial_images field type")
}
v := int(partialImages.Int())
req.PartialImages = &v
}
if req.IsEdits() {
images := gjson.GetBytes(body, "images")
if images.Exists() {
if !images.IsArray() {
return fmt.Errorf("invalid images field type")
}
for _, item := range images.Array() {
if imageURL := strings.TrimSpace(item.Get("image_url").String()); imageURL != "" {
req.InputImageURLs = append(req.InputImageURLs, imageURL)
continue
}
if item.Get("file_id").Exists() {
return fmt.Errorf("images[].file_id is not supported (use images[].image_url instead)")
}
}
}
if maskImageURL := strings.TrimSpace(gjson.GetBytes(body, "mask.image_url").String()); maskImageURL != "" {
req.MaskImageURL = maskImageURL
req.HasMask = true
}
if gjson.GetBytes(body, "mask.file_id").Exists() {
return fmt.Errorf("mask.file_id is not supported (use mask.image_url instead)")
}
if len(req.InputImageURLs) == 0 {
return fmt.Errorf("images[].image_url is required")
}
}
req.HasNativeOptions = hasOpenAINativeImageOptions(func(path string) bool {
return gjson.GetBytes(body, path).Exists()
})
return nil
}
func parseOpenAIImagesMultipartRequest(body []byte, contentType string, req *OpenAIImagesRequest) error {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return fmt.Errorf("invalid multipart content-type: %w", err)
}
boundary := strings.TrimSpace(params["boundary"])
if boundary == "" {
return fmt.Errorf("multipart boundary is required")
}
reader := multipart.NewReader(bytes.NewReader(body), boundary)
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read multipart body: %w", err)
}
name := strings.TrimSpace(part.FormName())
if name == "" {
_ = part.Close()
continue
}
data, err := io.ReadAll(io.LimitReader(part, openAIImageMaxUploadPartSize))
_ = part.Close()
if err != nil {
return fmt.Errorf("read multipart field %s: %w", name, err)
}
fileName := strings.TrimSpace(part.FileName())
if fileName != "" {
partContentType := strings.TrimSpace(part.Header.Get("Content-Type"))
if name == "mask" && len(data) > 0 {
req.HasMask = true
width, height := parseOpenAIImageDimensions(part.Header)
maskUpload := OpenAIImagesUpload{
FieldName: name,
FileName: fileName,
ContentType: partContentType,
Data: data,
Width: width,
Height: height,
}
req.MaskUpload = &maskUpload
}
if name == "image" || strings.HasPrefix(name, "image[") {
width, height := parseOpenAIImageDimensions(part.Header)
req.Uploads = append(req.Uploads, OpenAIImagesUpload{
FieldName: name,
FileName: fileName,
ContentType: partContentType,
Data: data,
Width: width,
Height: height,
})
}
continue
}
value := strings.TrimSpace(string(data))
switch name {
case "model":
req.Model = value
req.ExplicitModel = value != ""
case "prompt":
req.Prompt = value
case "size":
req.Size = value
req.ExplicitSize = value != ""
case "response_format":
req.ResponseFormat = strings.ToLower(value)
case "stream":
parsed, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid stream field value")
}
req.Stream = parsed
case "n":
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return fmt.Errorf("n must be a positive integer")
}
req.N = n
case "quality":
req.Quality = value
req.HasNativeOptions = true
case "background":
req.Background = value
req.HasNativeOptions = true
case "output_format":
req.OutputFormat = value
req.HasNativeOptions = true
case "moderation":
req.Moderation = value
req.HasNativeOptions = true
case "input_fidelity":
req.InputFidelity = value
req.HasNativeOptions = true
case "style":
req.Style = value
req.HasNativeOptions = true
case "output_compression":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid output_compression field value")
}
req.OutputCompression = &n
req.HasNativeOptions = true
case "partial_images":
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid partial_images field value")
}
req.PartialImages = &n
req.HasNativeOptions = true
default:
if isOpenAINativeImageOption(name) && value != "" {
req.HasNativeOptions = true
}
}
}
if len(req.Uploads) == 0 && req.IsEdits() {
return fmt.Errorf("image file is required")
}
return nil
}
func parseOpenAIImageDimensions(_ textproto.MIMEHeader) (int, int) {
return 0, 0
}
func applyOpenAIImagesDefaults(req *OpenAIImagesRequest) {
if req == nil {
return
}
if req.N <= 0 {
req.N = 1
}
if strings.TrimSpace(req.Model) != "" {
req.Model = strings.TrimSpace(req.Model)
return
}
req.Model = "gpt-image-2"
}
func isOpenAIImageGenerationModel(model string) bool {
return IsGPTImageGenerationModel(model) || isGrokImageGenerationModel(model)
}
// IsGPTImageGenerationModel identifies the GPT native image-generation model family.
func IsGPTImageGenerationModel(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
return strings.HasPrefix(model, "gpt-image-")
}
func isGrokImageGenerationModel(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
return model == "grok-imagine" ||
model == "grok-imagine-edit" ||
strings.HasPrefix(model, "grok-imagine-image")
}
func validateOpenAIImagesModel(model string) error {
model = strings.TrimSpace(model)
if isOpenAIImageGenerationModel(model) {
return nil
}
if model == "" {
return fmt.Errorf("images endpoint requires an image model")
}
return fmt.Errorf("images endpoint requires an image model, got %q", model)
}
func normalizeOpenAIImagesEndpointPath(path string) string {
trimmed := strings.TrimSpace(path)
switch {
case strings.Contains(trimmed, "/images/generations"):
return openAIImagesGenerationsEndpoint
case strings.Contains(trimmed, "/images/edits"):
return openAIImagesEditsEndpoint
default:
return ""
}
}
func classifyOpenAIImagesCapability(req *OpenAIImagesRequest) OpenAIImagesCapability {
if req == nil {
return OpenAIImagesCapabilityNative
}
if req.ExplicitModel || req.ExplicitSize {
return OpenAIImagesCapabilityNative
}
model := strings.ToLower(strings.TrimSpace(req.Model))
if !strings.HasPrefix(model, "gpt-image-") {
return OpenAIImagesCapabilityNative
}
if req.Stream || req.N != 1 || req.HasMask || req.HasNativeOptions {
return OpenAIImagesCapabilityNative
}
if req.IsEdits() && !req.Multipart {
return OpenAIImagesCapabilityNative
}
if req.ResponseFormat != "" && req.ResponseFormat != "b64_json" {
return OpenAIImagesCapabilityNative
}
return OpenAIImagesCapabilityBasic
}
func hasOpenAINativeImageOptions(exists func(path string) bool) bool {
for _, path := range []string{
"background",
"quality",
"style",
"output_format",
"output_compression",
"moderation",
"input_fidelity",
"partial_images",
} {
if exists(path) {
return true
}
}
return false
}
func isOpenAINativeImageOption(name string) bool {
switch strings.TrimSpace(strings.ToLower(name)) {
case "background", "quality", "style", "output_format", "output_compression", "moderation", "input_fidelity", "partial_images":
return true
default:
return false
}
}
func normalizeOpenAIImageSizeTier(size string) string {
return NormalizeImageBillingTierOrDefault(size)
}
func (s *OpenAIGatewayService) ForwardImages(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
parsed *OpenAIImagesRequest,
channelMappedModel string,
) (*OpenAIForwardResult, error) {
if parsed == nil {
return nil, fmt.Errorf("parsed images request is required")
}
switch account.Type {
case AccountTypeAPIKey:
return s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel)
case AccountTypeOAuth:
return s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel)
default:
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
}
}
func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
parsed *OpenAIImagesRequest,
channelMappedModel string,
) (*OpenAIForwardResult, error) {
startTime := time.Now()
requestModel := strings.TrimSpace(parsed.Model)
if mapped := strings.TrimSpace(channelMappedModel); mapped != "" {
requestModel = mapped
}
if err := validateOpenAIImagesModel(requestModel); err != nil {
return nil, err
}
upstreamModel := account.GetMappedModel(requestModel)
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
return nil, err
}
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI] Images request routing request_model=%s upstream_model=%s endpoint=%s account_type=%s",
strings.TrimSpace(parsed.Model),
upstreamModel,
parsed.Endpoint,
account.Type,
)
forwardBody, forwardContentType, err := rewriteOpenAIImagesModel(body, parsed.ContentType, upstreamModel)
if err != nil {
return nil, err
}
// 生图是长耗时、上游侧已产生实际成本的操作:客户端中途断开不应连带取消上游请求。
// detachStreamUpstreamContext 在非流式时原样返回请求 context,于是客户端一断开
// 就把已经在出图的上游调用打断成 context canceled,网关记 502、不扣费,而上游那边
// 图已经生成并计费。同一端点的 OAuth 分支 forwardOpenAIImagesOAuth 以及 Grok 媒体
// 路径本来就无条件脱钩,这里对齐;上游侧仍由 ResponseHeaderTimeout 兜底。
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
defer releaseUpstreamCtx()
token, _, err := s.GetAccessToken(upstreamCtx, account)
if err != nil {
return nil, err
}
upstreamReq, err := s.buildOpenAIImagesRequest(upstreamCtx, c, account, forwardBody, forwardContentType, token, parsed.Endpoint)
if err != nil {
return nil, err
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
upstreamStart := time.Now()
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
if err != nil {
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: 0,
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
Kind: "request_error",
Message: safeErr,
})
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
}
if resp.StatusCode >= 400 {
respBody := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
respBody = s.redactAgentIdentitySensitiveBody(upstreamCtx, account, respBody)
resp.Body = io.NopCloser(bytes.NewReader(respBody))
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
Kind: "failover",
Message: upstreamMsg,
})
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
}
defer func() { _ = resp.Body.Close() }()
var usage OpenAIUsage
imageCount := parsed.N
var firstTokenMs *int
if parsed.Stream && isEventStreamResponse(resp.Header) {
streamUsage, streamCount, streamSizes, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime)
if err != nil {
if streamCount > 0 {
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: streamUsage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: ttft,
ImageCount: streamCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: streamSizes,
}, err
}
return nil, err
}
usage = streamUsage
imageCount = streamCount
imageOutputSizes := streamSizes
firstTokenMs = ttft
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: imageOutputSizes,
}, nil
} else {
nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
if err != nil {
return nil, err
}
usage = nonStreamUsage
if nonStreamCount > 0 {
imageCount = nonStreamCount
}
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
Model: requestModel,
UpstreamModel: upstreamModel,
Stream: parsed.Stream,
ResponseHeaders: resp.Header.Clone(),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: parsed.SizeTier,
ImageInputSize: parsed.Size,
ImageOutputSizes: nonStreamSizes,
}, nil
}
}
func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
contentType string,
token string,
endpoint string,
) (*http.Request, error) {
targetURL := openAIImagesGenerationsURL
if endpoint == openAIImagesEditsEndpoint {
targetURL = openAIImagesEditsURL
}
baseURL := account.GetOpenAIBaseURL()
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, err
}
targetURL = buildOpenAIImagesURL(validatedURL, endpoint)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
if err != nil {
return nil, fmt.Errorf("build openai authentication headers: %w", err)
}
for key, values := range authHeaders {
for _, value := range values {
req.Header.Add(key, value)
}
}
for key, values := range c.Request.Header {
if !openaiPassthroughAllowedHeaders[strings.ToLower(key)] {
continue
}
for _, value := range values {
req.Header.Add(key, value)
}
}
customUA := account.GetOpenAIUserAgent()
if customUA != "" {
req.Header.Set("User-Agent", customUA)
}
if strings.TrimSpace(contentType) != "" {
req.Header.Set("Content-Type", contentType)
}
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
func buildOpenAIImagesURL(base string, endpoint string) string {
return buildOpenAIEndpointURL(base, endpoint)
}
func rewriteOpenAIImagesModel(body []byte, contentType string, model string) ([]byte, string, error) {
model = strings.TrimSpace(model)
if model == "" {
return body, contentType, nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && strings.EqualFold(mediaType, "multipart/form-data") {
rewrittenBody, rewrittenType, rewriteErr := rewriteOpenAIImagesMultipartModel(body, contentType, model)
return rewrittenBody, rewrittenType, rewriteErr
}
rewritten, err := sjson.SetBytes(body, "model", model)
if err != nil {
return nil, "", fmt.Errorf("rewrite image request model: %w", err)
}
return rewritten, contentType, nil
}
func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model string) ([]byte, string, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", fmt.Errorf("parse multipart content-type: %w", err)
}
boundary := strings.TrimSpace(params["boundary"])
if boundary == "" {
return nil, "", fmt.Errorf("multipart boundary is required")
}
reader := multipart.NewReader(bytes.NewReader(body), boundary)
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
modelWritten := false
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", fmt.Errorf("read multipart body: %w", err)
}
formName := strings.TrimSpace(part.FormName())
partHeader := cloneMultipartHeader(part.Header)
target, err := writer.CreatePart(partHeader)
if err != nil {
_ = part.Close()
return nil, "", fmt.Errorf("create multipart part: %w", err)
}
if formName == "model" && part.FileName() == "" {
if _, err := target.Write([]byte(model)); err != nil {
_ = part.Close()
return nil, "", fmt.Errorf("rewrite multipart model: %w", err)
}
modelWritten = true
_ = part.Close()
continue
}
if _, err := io.Copy(target, part); err != nil {
_ = part.Close()
return nil, "", fmt.Errorf("copy multipart part: %w", err)
}
_ = part.Close()
}
if !modelWritten {
if err := writer.WriteField("model", model); err != nil {
return nil, "", fmt.Errorf("append multipart model field: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, "", fmt.Errorf("finalize multipart body: %w", err)
}
return buffer.Bytes(), writer.FormDataContentType(), nil
}
func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
dst := make(textproto.MIMEHeader, len(src))
for key, values := range src {
copied := make([]string, len(values))
copy(copied, values)
dst[key] = copied
}
return dst
}
func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return OpenAIUsage{}, 0, nil, err
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json"
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" {
contentType = upstreamType
}
}
c.Data(resp.StatusCode, contentType, body)
usage, _ := extractOpenAIUsageFromJSONBytes(body)
return usage, extractOpenAIImageCountFromJSONBytes(body), collectOpenAIResponseImageOutputSizesFromJSONBytes(body), nil
}
func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
resp *http.Response,
c *gin.Context,
startTime time.Time,
) (OpenAIUsage, int, []string, *int, error) {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "text/event-stream"
}
c.Status(resp.StatusCode)
c.Header("Content-Type", contentType)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return OpenAIUsage{}, 0, nil, nil, fmt.Errorf("streaming is not supported by response writer")
}
usage := OpenAIUsage{}
imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
clientDisconnected := false
lastDownstreamWriteAt := time.Now()
var fallbackBody bytes.Buffer
fallbackBytes := int64(0)
fallbackLimit := resolveUpstreamResponseReadLimit(s.cfg)
seenSSEData := false
fallbackTooLarge := false
var sseData openAISSEDataAccumulator
processSSEData := func(dataBytes []byte) {
seenSSEData = true
fallbackBody.Reset()
fallbackBytes = 0
mergeOpenAIUsage(&usage, dataBytes)
imageCounter.AddSSEData(dataBytes)
}
flushSSEEvent := func() {
sseData.Flush(processSSEData)
}
processLine := func(line []byte) {
if len(line) == 0 {
return
}
if firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
if !clientDisconnected {
if _, writeErr := c.Writer.Write(line); writeErr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing")
} else {
flusher.Flush()
lastDownstreamWriteAt = time.Now()
}
}
trimmedLine := strings.TrimRight(string(line), "\r\n")
if _, ok := extractOpenAISSEDataLine(trimmedLine); ok || strings.TrimSpace(trimmedLine) == "" {
sseData.AddLine(trimmedLine, processSSEData)
return
}
if !seenSSEData && !fallbackTooLarge {
fallbackBytes += int64(len(line))
if fallbackBytes <= fallbackLimit {
_, _ = fallbackBody.Write(line)
} else {
fallbackTooLarge = true
fallbackBody.Reset()
}
}
}
finalizeFallbackBody := func() {
if seenSSEData || fallbackBody.Len() == 0 {
return
}
body := bytes.TrimSpace(fallbackBody.Bytes())
if len(body) == 0 {
return
}
mergeOpenAIUsage(&usage, body)
imageCounter.AddJSONResponse(body)
}
streamInterval := s.openAIImageStreamDataInterval()
keepaliveInterval := s.openAIImageStreamKeepaliveInterval()
if streamInterval <= 0 && keepaliveInterval <= 0 {
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadBytes('\n')
processLine(line)
if err == io.EOF {
break
}
if err != nil {
flushSSEEvent()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, err
}
}
flushSSEEvent()
finalizeFallbackBody()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
}
type readEvent struct {
line []byte
err error
}
events := make(chan readEvent, 16)
done := make(chan struct{})
sendEvent := func(ev readEvent) bool {
select {
case events <- ev:
return true
case <-done:
return false
}
}
var lastReadAt int64
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
go func() {
defer close(events)
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
}
if len(line) > 0 && !sendEvent(readEvent{line: line}) {
return
}
if err == io.EOF {
return
}
if err != nil {
_ = sendEvent(readEvent{err: err})
return
}
}
}()
defer close(done)
var intervalTicker *time.Ticker
if streamInterval > 0 {
intervalTicker = time.NewTicker(streamInterval)
defer intervalTicker.Stop()
}
var intervalCh <-chan time.Time
if intervalTicker != nil {
intervalCh = intervalTicker.C
}
var keepaliveTicker *time.Ticker
if keepaliveInterval > 0 {
keepaliveTicker = time.NewTicker(keepaliveInterval)
defer keepaliveTicker.Stop()
}
var keepaliveCh <-chan time.Time
if keepaliveTicker != nil {
keepaliveCh = keepaliveTicker.C
}
for {
select {
case ev, ok := <-events:
if !ok {
flushSSEEvent()
finalizeFallbackBody()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, nil
}
if ev.err != nil {
flushSSEEvent()
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, ev.err
}
processLine(ev.line)
case <-intervalCh:
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) < streamInterval {
continue
}
if clientDisconnected {
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
}
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval)
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
return usage, imageCounter.Count(), imageCounter.Sizes(), firstTokenMs, fmt.Errorf("image stream data interval timeout")
case <-keepaliveCh:
if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
continue
}
if _, writeErr := io.WriteString(c.Writer, ":\n\n"); writeErr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected during keepalive, continue draining upstream for billing")
continue
}
flusher.Flush()
lastDownstreamWriteAt = time.Now()
}
}
}
func (s *OpenAIGatewayService) openAIImageStreamDataInterval() time.Duration {
if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamDataIntervalTimeout <= 0 {
return 0
}
return time.Duration(s.cfg.Gateway.ImageStreamDataIntervalTimeout) * time.Second
}
func (s *OpenAIGatewayService) openAIImageStreamKeepaliveInterval() time.Duration {
if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamKeepaliveInterval <= 0 {
return 0
}
return time.Duration(s.cfg.Gateway.ImageStreamKeepaliveInterval) * time.Second
}
func extractOpenAIImagesBillableCountFromJSONBytes(body []byte) int {
if count := extractOpenAIImageCountFromJSONBytes(body); count > 0 {
return count
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return 0
}
if count := int(gjson.GetBytes(body, "usage.images").Int()); count > 0 {
return count
}
if count := int(gjson.GetBytes(body, "tool_usage.image_gen.images").Int()); count > 0 {
return count
}
eventType := strings.TrimSpace(gjson.GetBytes(body, "type").String())
if eventType == "" || !strings.HasSuffix(eventType, ".completed") {
return 0
}
if gjson.GetBytes(body, "b64_json").Exists() || gjson.GetBytes(body, "url").Exists() {
return 1
}
return 0
}
func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) {
if dst == nil {
return
}
if parsed, ok := extractOpenAIUsageFromJSONBytes(body); ok {
if parsed.InputTokens > 0 {
dst.InputTokens = parsed.InputTokens
}
if parsed.OutputTokens > 0 {
dst.OutputTokens = parsed.OutputTokens
}
if parsed.CacheReadInputTokens > 0 {
dst.CacheReadInputTokens = parsed.CacheReadInputTokens
}
if parsed.ImageInputTokens > 0 {
dst.ImageInputTokens = parsed.ImageInputTokens
}
if parsed.ImageOutputTokens > 0 {
dst.ImageOutputTokens = parsed.ImageOutputTokens
}
}
}
func extractOpenAIImageCountFromJSONBytes(body []byte) int {
return countOpenAIResponseImageOutputsFromJSONBytes(body)
}
type openAIImagePointerInfo struct {
Pointer string
DownloadURL string
B64JSON string
MimeType string
Prompt string
}
func collectOpenAIImagePointers(body []byte) []openAIImagePointerInfo {
if len(body) == 0 {
return nil
}
prompt := ""
for _, path := range []string{
"message.metadata.dalle.prompt",
"metadata.dalle.prompt",
"revised_prompt",
} {
if value := strings.TrimSpace(gjson.GetBytes(body, path).String()); value != "" {
prompt = value
break
}
}
matches := openAIImagePointerMatches(body)
out := make([]openAIImagePointerInfo, 0, len(matches))
for _, pointer := range matches {
out = append(out, openAIImagePointerInfo{Pointer: pointer, Prompt: prompt})
}
return mergeOpenAIImagePointerInfos(out, collectOpenAIImageInlineAssets(body, prompt))
}
func openAIImagePointerMatches(body []byte) []string {
raw := string(body)
matches := make([]string, 0, 4)
for _, prefix := range []string{"file-service://", "sediment://"} {
start := 0
for {
idx := strings.Index(raw[start:], prefix)
if idx < 0 {
break
}
idx += start
end := idx + len(prefix)
for end < len(raw) {
ch := raw[end]
if ch != '-' && ch != '_' &&
(ch < '0' || ch > '9') &&
(ch < 'a' || ch > 'z') &&
(ch < 'A' || ch > 'Z') {
break
}
end++
}
matches = append(matches, raw[idx:end])
start = end
}
}
return dedupeStrings(matches)
}
func mergeOpenAIImagePointerInfos(existing []openAIImagePointerInfo, next []openAIImagePointerInfo) []openAIImagePointerInfo {
if len(next) == 0 {
return existing
}
seen := make(map[string]openAIImagePointerInfo, len(existing)+len(next))
out := make([]openAIImagePointerInfo, 0, len(existing)+len(next))
for _, item := range existing {
if key := item.identityKey(); key != "" {
seen[key] = item
}
out = append(out, item)
}
for _, item := range next {
key := item.identityKey()
if key == "" {
continue
}
if existingItem, ok := seen[key]; ok {
merged := mergeOpenAIImagePointerInfo(existingItem, item)
if merged != existingItem {
for i := range out {
if out[i].identityKey() == key {
out[i] = merged
break
}
}
seen[key] = merged
}
continue
}
seen[key] = item
out = append(out, item)
}
return out
}
func (i openAIImagePointerInfo) identityKey() string {
switch {
case strings.TrimSpace(i.Pointer) != "":
return "pointer:" + strings.TrimSpace(i.Pointer)
case strings.TrimSpace(i.DownloadURL) != "":
return "download:" + strings.TrimSpace(i.DownloadURL)
case strings.TrimSpace(i.B64JSON) != "":
b64 := strings.TrimSpace(i.B64JSON)
if len(b64) > 64 {
b64 = b64[:64]
}
return "b64:" + b64
default:
return ""
}
}
func mergeOpenAIImagePointerInfo(existing, next openAIImagePointerInfo) openAIImagePointerInfo {
merged := existing
if strings.TrimSpace(merged.Pointer) == "" {
merged.Pointer = next.Pointer
}
if strings.TrimSpace(merged.DownloadURL) == "" {
merged.DownloadURL = next.DownloadURL
}
if strings.TrimSpace(merged.B64JSON) == "" {
merged.B64JSON = next.B64JSON
}
if strings.TrimSpace(merged.MimeType) == "" {
merged.MimeType = next.MimeType
}
if strings.TrimSpace(merged.Prompt) == "" {
merged.Prompt = next.Prompt
}
return merged
}
func resolveOpenAIImageBytes(
ctx context.Context,
client *req.Client,
headers http.Header,
conversationID string,
pointer openAIImagePointerInfo,
errorBodyReadLimit int64,
) ([]byte, error) {
if normalized := normalizeOpenAIImageBase64(pointer.B64JSON); normalized != "" {
return base64.StdEncoding.DecodeString(normalized)
}
if downloadURL := strings.TrimSpace(pointer.DownloadURL); downloadURL != "" {
return downloadOpenAIImageBytes(ctx, client, headers, downloadURL, errorBodyReadLimit)
}
if strings.TrimSpace(pointer.Pointer) == "" {
return nil, fmt.Errorf("image asset is missing pointer, url, and base64 data")
}
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer, errorBodyReadLimit)
if err != nil {
return nil, err
}
return downloadOpenAIImageBytes(ctx, client, headers, downloadURL, errorBodyReadLimit)
}
func normalizeOpenAIImageBase64(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "data:") {
if idx := strings.Index(raw, ","); idx >= 0 && idx+1 < len(raw) {
raw = raw[idx+1:]
}
}
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "=") + strings.Repeat("=", (4-len(raw)%4)%4)
if raw == "" {
return ""
}
if _, err := base64.StdEncoding.DecodeString(raw); err != nil {
return ""
}
return raw
}
func collectOpenAIImageInlineAssets(body []byte, fallbackPrompt string) []openAIImagePointerInfo {
if len(body) == 0 || !gjson.ValidBytes(body) {
return nil
}
var decoded any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil
}
var out []openAIImagePointerInfo
walkOpenAIImageInlineAssets(decoded, strings.TrimSpace(fallbackPrompt), &out)
return out
}
func walkOpenAIImageInlineAssets(node any, prompt string, out *[]openAIImagePointerInfo) {
switch value := node.(type) {
case map[string]any:
localPrompt := prompt
for _, key := range []string{"revised_prompt", "image_gen_title", "prompt"} {
if v, ok := value[key].(string); ok && strings.TrimSpace(v) != "" {
localPrompt = strings.TrimSpace(v)
break
}
}
item := openAIImagePointerInfo{
Prompt: localPrompt,
Pointer: firstNonEmptyString(value["asset_pointer"], value["pointer"]),
DownloadURL: firstNonEmptyString(value["download_url"], value["url"], value["image_url"]),
B64JSON: firstNonEmptyString(value["b64_json"], value["base64"], value["image_base64"]),
MimeType: firstNonEmptyString(value["mime_type"], value["mimeType"], value["content_type"]),
}
switch {
case strings.HasPrefix(strings.TrimSpace(item.Pointer), "file-service://"),
strings.HasPrefix(strings.TrimSpace(item.Pointer), "sediment://"),
isLikelyOpenAIImageDownloadURL(item.DownloadURL),
normalizeOpenAIImageBase64(item.B64JSON) != "":
*out = append(*out, item)
}
for _, child := range value {
walkOpenAIImageInlineAssets(child, localPrompt, out)
}
case []any:
for _, child := range value {
walkOpenAIImageInlineAssets(child, prompt, out)
}
}
}
func firstNonEmptyString(values ...any) string {
for _, value := range values {
if s, ok := value.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func isLikelyOpenAIImageDownloadURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if strings.HasPrefix(strings.ToLower(raw), "data:image/") {
return true
}
if !strings.HasPrefix(strings.ToLower(raw), "http://") && !strings.HasPrefix(strings.ToLower(raw), "https://") {
return false
}
lower := strings.ToLower(raw)
return strings.Contains(lower, "/download") ||
strings.Contains(lower, ".png") ||
strings.Contains(lower, ".jpg") ||
strings.Contains(lower, ".jpeg") ||
strings.Contains(lower, ".webp")
}
func fetchOpenAIImageDownloadURL(
ctx context.Context,
client *req.Client,
headers http.Header,
conversationID string,
pointer string,
errorBodyReadLimit int64,
) (string, error) {
url := ""
allowConversationRetry := false
switch {
case strings.HasPrefix(pointer, "file-service://"):
fileID := strings.TrimPrefix(pointer, "file-service://")
url = fmt.Sprintf("%s/%s/download", openAIChatGPTFilesURL, fileID)
case strings.HasPrefix(pointer, "sediment://"):
attachmentID := strings.TrimPrefix(pointer, "sediment://")
url = fmt.Sprintf("https://chatgpt.com/backend-api/conversation/%s/attachment/%s/download", conversationID, attachmentID)
allowConversationRetry = true
default:
return "", fmt.Errorf("unsupported image pointer: %s", pointer)
}
var lastErr error
for attempt := 0; attempt < 8; attempt++ {
var result struct {
DownloadURL string `json:"download_url"`
}
resp, err := client.R().
SetContext(ctx).
SetHeaders(headerToMap(headers)).
SetSuccessResult(&result).
Get(url)
if err != nil {
lastErr = err
} else if resp.IsSuccessState() && strings.TrimSpace(result.DownloadURL) != "" {
return strings.TrimSpace(result.DownloadURL), nil
} else {
statusErr := newOpenAIImageStatusError(resp, "fetch image download url failed", errorBodyReadLimit)
if !allowConversationRetry || !isOpenAIImageTransientConversationNotFoundError(statusErr) {
return "", statusErr
}
lastErr = statusErr
}
if attempt == 7 {
break
}
timer := time.NewTimer(750 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return "", ctx.Err()
case <-timer.C:
}
}
if lastErr == nil {
lastErr = fmt.Errorf("fetch image download url failed")
}
return "", lastErr
}
func downloadOpenAIImageBytes(ctx context.Context, client *req.Client, headers http.Header, downloadURL string, errorBodyReadLimit int64) ([]byte, error) {
request := client.R().
SetContext(ctx).
DisableAutoReadResponse()
if strings.HasPrefix(downloadURL, openAIChatGPTStartURL) {
downloadHeaders := cloneHTTPHeader(headers)
downloadHeaders.Set("Accept", "image/*,*/*;q=0.8")
downloadHeaders.Del("Content-Type")
request.SetHeaders(headerToMap(downloadHeaders))
} else {
userAgent := strings.TrimSpace(headers.Get("User-Agent"))
if userAgent == "" {
userAgent = openAIImageBackendUserAgent
}
request.SetHeader("User-Agent", userAgent)
}
resp, err := request.Get(downloadURL)
if err != nil {
return nil, err
}
defer func() {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, newOpenAIImageStatusError(resp, "download image bytes failed", errorBodyReadLimit)
}
return io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes))
}
type openAIImageStatusError struct {
StatusCode int
Message string
ResponseBody []byte
ResponseHeaders http.Header
RequestID string
URL string
}
func (e *openAIImageStatusError) Error() string {
if e == nil {
return "openai image backend request failed"
}
if e.Message != "" {
return e.Message
}
if e.StatusCode > 0 {
return fmt.Sprintf("openai image backend request failed: status %d", e.StatusCode)
}
return "openai image backend request failed"
}
func newOpenAIImageStatusError(resp *req.Response, fallback string, errorBodyReadLimit int64) error {
if resp == nil {
if strings.TrimSpace(fallback) == "" {
fallback = "openai image backend request failed"
}
return fmt.Errorf("%s", fallback)
}
statusCode := resp.StatusCode
headers := http.Header(nil)
requestID := ""
requestURL := ""
body := []byte(nil)
if resp.Response != nil {
headers = resp.Header.Clone()
requestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
if resp.Request != nil && resp.Request.URL != nil {
requestURL = resp.Request.URL.String()
}
if resp.Body != nil {
if errorBodyReadLimit <= 0 {
errorBodyReadLimit = openAIUpstreamErrorBodyReadLimit
}
body, _ = io.ReadAll(io.LimitReader(resp.Body, errorBodyReadLimit))
_ = resp.Body.Close()
}
}
message := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(body))
if message == "" {
prefix := strings.TrimSpace(fallback)
if prefix == "" {
prefix = "openai image backend request failed"
}
message = fmt.Sprintf("%s: status %d", prefix, statusCode)
}
return &openAIImageStatusError{
StatusCode: statusCode,
Message: message,
ResponseBody: body,
ResponseHeaders: headers,
RequestID: requestID,
URL: requestURL,
}
}
func isOpenAIImageTransientConversationNotFoundError(err error) bool {
statusErr, ok := err.(*openAIImageStatusError)
if !ok || statusErr == nil || statusErr.StatusCode != http.StatusNotFound {
return false
}
msg := strings.ToLower(strings.TrimSpace(statusErr.Message))
if strings.Contains(msg, "conversation_not_found") {
return true
}
if strings.Contains(msg, "conversation") && strings.Contains(msg, "not found") {
return true
}
bodyMsg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(statusErr.ResponseBody)))
if strings.Contains(bodyMsg, "conversation_not_found") {
return true
}
return strings.Contains(bodyMsg, "conversation") && strings.Contains(bodyMsg, "not found")
}
func cloneHTTPHeader(src http.Header) http.Header {
dst := make(http.Header, len(src))
for key, values := range src {
copied := make([]string, len(values))
copy(copied, values)
dst[key] = copied
}
return dst
}
func headerToMap(header http.Header) map[string]string {
if len(header) == 0 {
return nil
}
result := make(map[string]string, len(header))
for key, values := range header {
if len(values) == 0 {
continue
}
result[key] = values[0]
}
return result
}
func dedupeStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}